diff --git a/.changeset/rename-provider-to-wireprovider.md b/.changeset/rename-provider-to-wireprovider.md index 7df1bda9..a4b59d9b 100644 --- a/.changeset/rename-provider-to-wireprovider.md +++ b/.changeset/rename-provider-to-wireprovider.md @@ -1,5 +1,5 @@ --- -"@parity/truapi": patch +"@parity/truapi": minor --- Rename the exported `Provider` transport type to `WireProvider` to make its role explicit. It is the low-level SCALE-wire-frame pipe (a `MessagePort` or iframe `postMessage` channel) that `createTransport` runs on. The `createIframeProvider` / `createMessagePortProvider` factories are unchanged; only the type name moves. Consumers importing `Provider` should import `WireProvider` instead. diff --git a/.changeset/truapi-sandbox-bootstrap.md b/.changeset/truapi-sandbox-bootstrap.md index 14eb6333..1b94d436 100644 --- a/.changeset/truapi-sandbox-bootstrap.md +++ b/.changeset/truapi-sandbox-bootstrap.md @@ -1,5 +1,5 @@ --- -"@parity/truapi": patch +"@parity/truapi": minor --- Add the `@parity/truapi/sandbox` entry point: host-environment detection (`isCorrectEnvironment`), a lazily-built cached client (`getClientSync`, `null` outside a host container), and a `subscribeConnectionStatus` connected/disconnected listener. Browser-embedded hosts can bootstrap a client without assembling the transport by hand. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..4fb9ca56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +rust/crates/truapi-codegen/tests/golden/* linguist-generated=true +rust/crates/truapi-server/src/generated/* linguist-generated=true diff --git a/.github/PULL_REQUEST_TEMPLATE/release.md b/.github/PULL_REQUEST_TEMPLATE/release.md index a2f7c0c9..f213a957 100644 --- a/.github/PULL_REQUEST_TEMPLATE/release.md +++ b/.github/PULL_REQUEST_TEMPLATE/release.md @@ -2,7 +2,7 @@ > [!IMPORTANT] > The PR title must start with `release:` for the publish workflow to fire. -> Example: `release: @parity/truapi 0.1.1`. +> Example: `release: @parity/truapi 0.1.1` or `release: @parity/truapi-host 0.1.1`. > Don't rewrite the squash commit subject in the merge dialog — the > `release:` prefix has to land on `main`. @@ -12,10 +12,9 @@ ### Checklist -- [ ] Ran `npm run changeset` and selected the bump type (patch / minor / major) +- [ ] Ran `npm run changeset` and selected the package + bump type (patch / minor / major) - [ ] Ran `npm run version-packages` to consume the changeset - [ ] `js/packages/truapi/package.json` version is bumped - [ ] `js/packages/truapi/CHANGELOG.md` has the new entry - [ ] `rust/crates/truapi/Cargo.toml` version matches `js/packages/truapi/package.json` - [ ] No leftover files under `.changeset/` (other than `config.json`) - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5652e58..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 @@ -76,6 +82,7 @@ jobs: - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly with: toolchain: nightly + components: rustfmt - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -89,6 +96,15 @@ jobs: - name: Run codegen run: ./scripts/codegen.sh + - name: Check generated Rust output is committed + run: | + git diff --exit-code -- \ + rust/crates/truapi-server/src/generated \ + rust/crates/truapi-server/src/wasm/generated_bridge.rs + + - name: Check Rust/TS wire table parity + run: TRUAPI_REQUIRE_GENERATED_TS=1 cargo test -p truapi-server --test wire_table_ts_parity + - name: Upload codegen output uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -100,10 +116,11 @@ jobs: js/packages/truapi/src/playground/codegen js/packages/truapi/src/explorer/codegen js/packages/truapi/src/explorer/versions.ts + js/packages/truapi-host/src/generated playground/test/generated ts-client: - name: '@parity/truapi' + name: "@parity/truapi" runs-on: ubuntu-latest needs: codegen env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 835f03fc..cafb47b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ name: Release # guarded by conclusion == success and a release: commit subject, and checks # out the pinned head_sha with persist-credentials: false. No untrusted PR code # ever runs in this privileged context. -on: # zizmor: ignore[dangerous-triggers] +on: # zizmor: ignore[dangerous-triggers] workflow_run: workflows: ["CI"] types: [completed] @@ -36,19 +36,37 @@ jobs: ref: ${{ github.event.workflow_run.head_sha }} persist-credentials: false - - name: Resolve target version + - name: Resolve target packages id: version run: | - version=$(jq -r '.version' js/packages/truapi/package.json) - tag="@parity/truapi@${version}" - if git rev-parse --verify --quiet "refs/tags/${tag}" >/dev/null; then - echo "Tag ${tag} already exists; nothing to publish." - echo "proceed=false" >> "$GITHUB_OUTPUT" - else - echo "Publishing ${tag}." + set -euo pipefail + packages=( + "@parity/truapi js/packages/truapi" + "@parity/truapi-host js/packages/truapi-host" + ) + pending="$(mktemp)" + for entry in "${packages[@]}"; do + name="${entry%% *}" + path="${entry#* }" + version="$(jq -r '.version' "${path}/package.json")" + tag="${name}@${version}" + if git rev-parse --verify --quiet "refs/tags/${tag}" >/dev/null; then + echo "Tag ${tag} already exists; skipping ${name}." + else + echo "Publishing ${tag}." + echo "${name}|${path}|${version}|${tag}" >> "${pending}" + fi + done + if [ -s "${pending}" ]; then echo "proceed=true" >> "$GITHUB_OUTPUT" - echo "version=${version}" >> "$GITHUB_OUTPUT" - echo "tag=${tag}" >> "$GITHUB_OUTPUT" + { + echo "packages<> "$GITHUB_OUTPUT" + else + echo "No unpublished package tags found." + echo "proceed=false" >> "$GITHUB_OUTPUT" fi - if: steps.version.outputs.proceed == 'true' @@ -74,25 +92,45 @@ jobs: if: steps.version.outputs.proceed == 'true' run: npm ci - - name: Build @parity/truapi + - name: Install wasm-pack + if: steps.version.outputs.proceed == 'true' && contains(steps.version.outputs.packages, '@parity/truapi-host|') + run: cargo install wasm-pack --version 0.14.0 --locked + + - name: Build packages if: steps.version.outputs.proceed == 'true' - run: ./scripts/codegen.sh + run: | + set -euo pipefail + ./scripts/codegen.sh + if grep -q '^@parity/truapi-host|' <<< "${STEPS_VERSION_OUTPUTS_PACKAGES}"; then + npm run build --prefix js/packages/truapi-host + npm run build:wasm --prefix js/packages/truapi-host + fi + env: + STEPS_VERSION_OUTPUTS_PACKAGES: ${{ steps.version.outputs.packages }} - name: Tag release if: steps.version.outputs.proceed == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - STEPS_VERSION_OUTPUTS_TAG: ${{ steps.version.outputs.tag }} run: | - gh api repos/${{ github.repository }}/git/refs \ - -f ref="refs/tags/${STEPS_VERSION_OUTPUTS_TAG}" \ - -f sha="${{ github.event.workflow_run.head_sha }}" + set -euo pipefail + tags=() + while IFS='|' read -r name path version tag; do + git tag "${tag}" + tags+=("${tag}") + done <<< "${STEPS_VERSION_OUTPUTS_PACKAGES}" + git push origin "${tags[@]}" + env: + STEPS_VERSION_OUTPUTS_PACKAGES: ${{ steps.version.outputs.packages }} - name: Pack package if: steps.version.outputs.proceed == 'true' run: | + set -euo pipefail mkdir -p artifacts - (cd js/packages/truapi && npm pack --pack-destination "$GITHUB_WORKSPACE/artifacts/") + while IFS='|' read -r name path version tag; do + (cd "${path}" && npm pack --pack-destination "$GITHUB_WORKSPACE/artifacts/") + done <<< "${STEPS_VERSION_OUTPUTS_PACKAGES}" + env: + STEPS_VERSION_OUTPUTS_PACKAGES: ${{ steps.version.outputs.packages }} - name: Upload package artifacts if: steps.version.outputs.proceed == 'true' @@ -115,10 +153,14 @@ jobs: if: steps.version.outputs.proceed == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - STEPS_VERSION_OUTPUTS_TAG: ${{ steps.version.outputs.tag }} - STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} + STEPS_VERSION_OUTPUTS_PACKAGES: ${{ steps.version.outputs.packages }} run: | - gh release create "${STEPS_VERSION_OUTPUTS_TAG}" \ - --title "@parity/truapi ${STEPS_VERSION_OUTPUTS_VERSION}" \ - --generate-notes \ - artifacts/*.tgz + set -euo pipefail + while IFS='|' read -r name path version tag; do + tarball_name="${name#@}" + tarball_name="${tarball_name//\//-}-${version}.tgz" + gh release create "${tag}" \ + --title "${name} ${version}" \ + --generate-notes \ + "artifacts/${tarball_name}" + done <<< "${STEPS_VERSION_OUTPUTS_PACKAGES}" diff --git a/.gitignore b/.gitignore index 9138e1a8..f0359297 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,16 @@ lerna-debug.log* node_modules target +# Local Bun side effects (repo lockfiles are npm/yarn) +bun.lock +bun.lockb + +# Gradle (Android workspace at repo root) +/.gradle/ +/build/ +/android/*/build/ +local.properties + # Environment / secrets (never commit real env files; keep example templates) .env .env.* @@ -39,3 +49,15 @@ playground/public/static.files # Auto-generated by truapi-codegen (typecheck fixtures for rustdoc ts blocks) playground/test/generated/ + +# Auto-generated FFI / WASM binding outputs +android/truapi-host/src/main/kotlin/generated/ +ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +ios/truapi-host/Sources/truapi_serverFFI/ +rust/crates/truapi-server/pkg/ +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/dist/wasm/ +js/packages/truapi-host-wasm/ diff --git a/.prettierrc b/.prettierrc index 8224a16a..7d4a0046 100644 --- a/.prettierrc +++ b/.prettierrc @@ -10,7 +10,10 @@ "endOfLine": "lf", "overrides": [ { - "files": "js/packages/truapi/src/**/*.test.ts", + "files": [ + "js/packages/truapi/src/**/*.test.ts", + "js/packages/truapi-host-wasm/src/**/*.test.ts" + ], "options": { "tabWidth": 4, "printWidth": 100 diff --git a/CLAUDE.md b/CLAUDE.md index 0116c828..46d8d44b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,17 +8,42 @@ This repo is the single source of truth for the TrUAPI protocol. It vendors `dot ``` rust/crates/ - truapi/ Rust trait + type definitions for protocol versions v0.1 and v0.2 + truapi/ Rust trait + type definitions for protocol versions v0.1 and v0.2 (canonical) truapi-codegen/ rustdoc JSON → TypeScript client + Rust dispatcher truapi-macros/ #[wire(id = N)] proc-macro + truapi-platform/ Host syscall traits (storage, navigation, consent, ...) + truapi-server/ Rust runtime hosts implement; ships as WASM (browser/node) + truapi-host-cli/ Headless pairing-host + signing-host CLIs that pair over the real People-chain statement store; local e2e signing-bot replacement js/packages/ - truapi/ @parity/truapi TS package; generated TS lives under ignored paths -playground/ Next.js interactive playground; deploys to truapi-playground.dot -hosts/dotli/ dotli submodule -docs/ design docs, RFCs, feature proposals -scripts/codegen.sh regenerate the TS client from the Rust crate + truapi/ @parity/truapi TS package; generated TS lives under ignored paths + truapi-host/ @parity/truapi-host: WASM-backed host runtime. Subpath entries: + `.` (shared host types), `/web` (iframe + Web + Worker), `/worker-runtime` (Worker entry). + WASM bundle (gitignored) under dist/wasm/web/, built via `make wasm` +playground/ Next.js interactive playground; deploys to truapi-playground.dot +hosts/dotli/ dotli submodule +docs/ design docs, RFCs, feature proposals +scripts/codegen.sh regenerate the TS client from the Rust crate ``` +### Crate + binding invariants + +- `truapi` is canonical; runtime crates re-export rather than redefine. New + syscall traits and host-side runtime types live in `truapi-platform` and + `truapi-server`, not in `truapi`. Any additions to `truapi` itself are limited + to additive `Display` impls. +- Outside the canonical `truapi` crate and its version-conversion impls, use + structs from `truapi::latest` for concrete protocol payload/error types. + Runtime crates should take envelopes from `truapi::versioned::*` and unwrap + them into latest payloads instead of spelling `truapi::v01::*` directly. +- `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 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 - Every `pub` Rust item (functions, methods, types, traits, modules, constants) carries a doc comment (`///` or `//!`). @@ -26,12 +51,28 @@ scripts/codegen.sh regenerate the TS client from the Rust crate - Do not add code comments or doc comments that narrate migrations, compatibility shims, or historical changes. Comments should describe only the current code. - Remove legacy compatibility code by default. Keep or add it only when explicitly requested. - In Rust format strings, prefer inlined variables: `"log value: {value:?}"` over `"log value: {:?}", value`. +- For Rust modules, prefer `foo.rs` plus an optional `foo/` directory for + child modules. Do not introduce new `foo/mod.rs` files unless preserving + generated output or an existing external convention. +- In runtime Rust code, prefer `core::` over `std::` for types that are + available in `core` (`core::pin::Pin`, `core::task::Poll`, `core::fmt`, and + similar). Keep `std::` for std-only APIs, tests, and std-only programs such + as `truapi-codegen`. - **No `any` in TypeScript types**: If a type can't be expressed cleanly, stop and ask the user whether to (a) refactor or import the right type or (b) add a scoped `// eslint-disable-next-line @typescript-eslint/no-explicit-any` exception. Never silently leave `any`. - Don't introduce typealias chains that just rename a public type from another crate (e.g. `pub type StorageError = crate::v01::HostLocalStorageReadError`). Use the canonical name directly. A typealias is only worth its indirection when it captures a real abstraction. -- After any code change, update `README.md` (and CLAUDE.md if the layout changed) so the top-level docs reflect what the repo actually contains. Stale docs are a regression. +- After any code change, update `README.md` (and CLAUDE.md if the layout changed) so the top-level docs reflect what the repo actually contains. Stale docs are a regression. When moving or removing docs, `rg` for the old path and update or remove stale links in README files, agent notes, skills, comments, and design docs. - In codegen emitters, prefer `indoc::writedoc!` / `formatdoc!` over chains of `writeln!`. A single `writedoc!` with a multi-line raw string keeps the emitted shape visible in source instead of fragmenting it across one-line `writeln!` calls. Reserve `writeln!` for the genuinely-one-line case (a single import, a single statement inside a loop). - In PR descriptions, issue comments, and other artifacts that outlive the conversation: describe the resulting state, not the transition between commits. Avoid "previously X, now Y", "we removed", "the old shim is gone", "this PR replaces", those read as ephemeral history once the PR is squash-merged. Write what the system _does_ after the change, not what each commit _changed_ on the way there. (Commit messages are the place for transition narrative; they survive in `git log` even after the squash.) +## Explanation style + +- For architecture, event-flow, and debugging explanations, start with a short + direct summary of the model before diving into long details. Prefer simple + statements like "the host sends a dirty signal; the core re-reads and derives + auth state" before listing each hop. +- Use diagrams only when they clarify ownership or message flow. Keep them + layered and label what is per-tab, shared, host-owned, and core-owned. + ## First-time setup ```bash @@ -116,6 +157,90 @@ submodule init + `bun install` and the per-pane `cd` discipline). Alternatively, with a deployed Polkadot Desktop Host installed, navigate to `https://dot.li/localhost:3000` from within it. +#### Local dotli + playground E2E notes + +Use `make dev DEBUG=1` from the repo root for the local host stack. It prepares +the ignored WASM/build artifacts, verifies dotli can resolve +`@parity/truapi-host`, then starts dotli on `:5173` and the playground on +`:3000`. Open `http://localhost:5173/localhost:3000`. + +When automating with Playwright, block service workers for smoke tests unless +the test is explicitly about SW behavior. Stale host/product bundles can mask +runtime fixes. Use a fresh cache-busting query string on +`http://localhost:5173/localhost:3000?...`, collect `pageerror` and +`console` messages, and fail on unexpected page errors. + +For interactive SSO checks, prefer a persistent headed Chrome profile and reuse +the same browser context across checks. SSO pairing needs a real phone QR scan, +and signing/resource-allocation flows may need web or mobile confirmation; if +the human or companion app is unavailable, skip those methods and record the +skip instead of treating it as a protocol failure. Non-interactive checks should +still verify that the playground renders, the TrUAPI debug panel receives +host/product events, generated examples can call non-confirmation methods, and +logout/relogin does not restore a stale session. + +The dotli Playwright e2e suite under `hosts/dotli/apps/host/tests/e2e/` +pairs through the signer-bot service. It requires `SIGNER_BOT_SVC_TOKEN`; +`SIGNER_BOT_BASE_URL` and `SIGNER_BOT_NETWORK` default to dotli CI's +`https://signing-bot-dev.novasama-tech.org/` and `paseo-next-v2`. Without the +token, do not treat the full suite as locally runnable. Use +`E2E_DOTLI_SMOKE=1 make e2e-dotli` for the no-phone QR smoke path. +If those signer-bot variables are not available in a worktree, check for a +repo-root `.env` and load or copy the values from there before falling back to +smoke mode. Prefer the current worktree's `.env` when it exists. + +For a fully automated local playground diagnosis run, use: + +```bash +SIGNER_BOT_SVC_TOKEN=... \ +make e2e-dotli +``` + +`make e2e-dotli` starts dotli preview and the playground, signs out any +restored host session, signs in through signer-bot by extracting the QR payload, +runs the playground Diagnosis screen, auto-accepts host-side Allow/Sign modals, +and writes `hosts/dotli/test-results/e2e-dotli/diagnosis-report.md`. + +Root CI runs the same target when it can read the private dotli submodule. It +needs `DOTLI_CHECKOUT_TOKEN` for submodule checkout; without that token, the +job warns and skips dotli e2e rather than failing unrelated PR checks. With +dotli access but without `SIGNER_BOT_SVC_TOKEN`, CI runs the no-phone smoke +path only. + +A useful no-phone smoke assertion is: + +```bash +E2E_DOTLI_SMOKE=1 make e2e-dotli +``` + +For manual debugging of that smoke path: + +1. Start `make dev DEBUG=1`. +2. Open `http://localhost:5173/localhost:3000?debug=truapi&cachebust=` with + service workers blocked. +3. Wait for `globalThis.__truapi?.setLogLevel`, call + `__truapi.setLogLevel("debug")`, and confirm the console logs + `[truapi worker] logLevel=debug providers=0`. +4. Click `#auth-button`, wait for `#auth-modal-backdrop.open`, and confirm: + the modal shows `Login with Polkadot Mobile`, `__truapi.getProviderCount()` + is greater than zero, worker frame/callback logs appear, and there are no + page errors. + +If `make dev` reports `EADDRINUSE` on `:5173` or the playground moves from +`:3000` to `:3001`, kill stale `preview-server.ts` / `next dev` processes and +restart the tmux session. Port drift causes false-negative local e2e results. + +Useful debug signals: + +```js +__truapi.setLogLevel("debug"); +sessionStorage.setItem("dotli:truapi-debug", "1"); +``` + +Reload after setting the debug-panel flag. Watch for `Unknown wire discriminant`, missing +`@parity/truapi-host` imports, worker WASM instantiation failures, and +debug-panel traffic disappearing when the login popup opens. + ## Deployment Pushes to `main` trigger `.github/workflows/deploy-playground.yml`, which builds `playground/` and publishes the static export to `truapi-playground.dot` via `bulletin-deploy`. diff --git a/Cargo.lock b/Cargo.lock index 84faa731..a38a473f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -37,6 +37,27 @@ 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 = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -99,6 +120,194 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ark-bls12-381" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +dependencies = [ + "ahash", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", + "itertools 0.13.0", + "num-bigint", + "num-integer", + "num-traits", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ed-on-bls12-381-bandersnatch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1786b2e3832f6f0f7c8d62d5d5a282f6952a1ab99981c54cd52b6ac1d8f02df5" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-std", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "arrayvec 0.7.6", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ark-poly" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +dependencies = [ + "ahash", + "ark-ff", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", + "rayon", +] + +[[package]] +name = "ark-scale" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985c81a9c7b23a72f62b7b20686d5326d2a9956806f37de9ee35cb1238faf0c0" +dependencies = [ + "ark-serialize", + "ark-std", + "parity-scale-codec", + "scale-info", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "arrayvec 0.7.6", + "digest 0.10.7", + "num-bigint", + "rayon", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.6", + "rayon", +] + +[[package]] +name = "ark-transcript" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c1c928edb9d8ff24cb5dcb7651d3a98494fff3099eee95c2404cd813a9139f" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "digest 0.10.7", + "rand_core 0.6.4", + "sha3", +] + +[[package]] +name = "ark-vrf" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b9bd02dbd2f282fe742d51f681adb2745a79dc8a025bc8d16cac9b255d55bf7" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ed-on-bls12-381-bandersnatch", + "ark-ff", + "ark-serialize", + "ark-std", + "digest 0.10.7", + "generic-array", + "rayon", + "sha2 0.10.9", + "w3f-ring-proof", + "zeroize", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -280,6 +489,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" @@ -299,6 +514,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ "bitcoin_hashes", + "rand 0.8.6", + "rand_core 0.6.4", + "serde", + "unicode-normalization", ] [[package]] @@ -328,6 +547,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" @@ -380,6 +608,19 @@ dependencies = [ "piper", ] +[[package]] +name = "bounded-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee8eddd066a8825ec5570528e6880471210fd5d88cb6abbe1cfdd51ca249c33" +dependencies = [ + "jam-codec", + "log", + "parity-scale-codec", + "scale-info", + "serde", +] + [[package]] name = "bs58" version = "0.5.1" @@ -441,6 +682,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -449,7 +696,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -619,6 +877,34 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -647,7 +933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -659,7 +945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -689,7 +975,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", @@ -709,6 +995,47 @@ 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 = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.7.10" @@ -831,12 +1158,24 @@ dependencies = [ "ed25519", "hashbrown 0.16.1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sha2 0.10.9", "subtle", "zeroize", ] +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.16.0" @@ -856,12 +1195,32 @@ dependencies = [ "generic-array", "group", "hkdf", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", ] +[[package]] +name = "enum-ordinalize" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -923,7 +1282,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -956,7 +1315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" dependencies = [ "byteorder", - "rand", + "rand 0.8.6", "rustc-hex", "static_assertions", ] @@ -988,6 +1347,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 +1374,7 @@ dependencies = [ "cfg-if", "parity-scale-codec", "scale-info", + "serde", ] [[package]] @@ -1147,10 +1525,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1159,7 +1540,8 @@ version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" dependencies = [ - "rand_core", + "rand 0.8.6", + "rand_core 0.6.4", ] [[package]] @@ -1225,16 +1607,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", "foldhash 0.1.5", ] @@ -1334,11 +1727,93 @@ dependencies = [ ] [[package]] -name = "httparse" -version = "1.10.1" +name = "http-body" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1427,6 +1902,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" @@ -1507,12 +1988,27 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1528,6 +2024,34 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jam-codec" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb948eace373d99de60501a02fb17125d30ac632570de20dccc74370cdd611b9" +dependencies = [ + "arrayvec 0.7.6", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "jam-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "jam-codec-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "319af585c4c8a6b5552a52b7787a1ab3e4d59df7614190b1f85b9b842488789d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "jni" version = "0.21.1" @@ -1685,7 +2209,17 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[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]] @@ -1740,7 +2274,7 @@ dependencies = [ "libsecp256k1-core", "libsecp256k1-gen-ecmult", "libsecp256k1-gen-genmult", - "rand", + "rand 0.8.6", "serde", "sha2 0.9.9", "typenum", @@ -1811,6 +2345,21 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1825,7 +2374,7 @@ checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" dependencies = [ "byteorder", "keccak", - "rand_core", + "rand_core 0.6.4", "zeroize", ] @@ -1862,7 +2411,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" dependencies = [ - "rand", + "rand 0.8.6", ] [[package]] @@ -2026,6 +2575,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -2117,7 +2672,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2129,7 +2684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2186,6 +2741,7 @@ dependencies = [ "fixed-hash", "impl-codec", "impl-serde", + "scale-info", "uint", ] @@ -2198,6 +2754,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" @@ -2207,6 +2785,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2236,7 +2870,18 @@ checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -2246,7 +2891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2258,6 +2903,41 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2267,6 +2947,61 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + [[package]] name = "ring" version = "0.17.14" @@ -2348,6 +3083,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -2401,6 +3137,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 +3152,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 +3245,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" @@ -2455,7 +3323,7 @@ dependencies = [ "curve25519-dalek", "getrandom_or_panic", "merlin", - "rand_core", + "rand_core 0.6.4", "serde_bytes", "sha2 0.10.9", "subtle", @@ -2578,6 +3446,31 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +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" @@ -2585,7 +3478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -2597,7 +3490,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -2609,7 +3502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -2666,6 +3559,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" @@ -2703,7 +3605,7 @@ dependencies = [ "bip39", "blake2-rfc", "bs58", - "chacha20", + "chacha20 0.9.1", "crossbeam-queue", "derive_more 2.1.1", "ed25519-zebra", @@ -2716,7 +3618,7 @@ dependencies = [ "hashbrown 0.16.1", "hex", "hmac 0.12.1", - "itertools", + "itertools 0.14.0", "libm", "libsecp256k1", "merlin", @@ -2727,7 +3629,7 @@ dependencies = [ "pbkdf2", "pin-project", "poly1305", - "rand", + "rand 0.8.6", "rand_chacha", "ruzstd", "schnorrkel", @@ -2759,7 +3661,7 @@ dependencies = [ "bip39", "blake2-rfc", "bs58", - "chacha20", + "chacha20 0.9.1", "crossbeam-queue", "derive_more 2.1.1", "ed25519-zebra", @@ -2772,7 +3674,7 @@ dependencies = [ "hashbrown 0.16.1", "hex", "hmac 0.12.1", - "itertools", + "itertools 0.14.0", "libm", "libsecp256k1", "merlin", @@ -2783,7 +3685,7 @@ dependencies = [ "pbkdf2", "pin-project", "poly1305", - "rand", + "rand 0.8.6", "rand_chacha", "ruzstd", "schnorrkel", @@ -2821,12 +3723,12 @@ dependencies = [ "futures-util", "hashbrown 0.16.1", "hex", - "itertools", + "itertools 0.14.0", "log", "lru", "parking_lot", "pin-project", - "rand", + "rand 0.8.6", "rand_chacha", "serde", "serde_json", @@ -2858,7 +3760,7 @@ dependencies = [ "futures", "httparse", "log", - "rand", + "rand 0.8.6", "sha1", ] @@ -2929,6 +3831,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 +3915,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" @@ -2976,11 +3969,39 @@ dependencies = [ "serde_json", "subxt-lightclient", "thiserror 2.0.18", + "tokio-util", "tracing", "url", "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" @@ -2992,6 +4013,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -3071,6 +4101,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" @@ -3102,9 +4141,11 @@ version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -3140,6 +4181,23 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -3186,6 +4244,51 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -3215,6 +4318,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", ] [[package]] @@ -3223,9 +4338,16 @@ version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", + "smallvec", "thread_local", + "tracing", "tracing-core", + "tracing-log", ] [[package]] @@ -3253,6 +4375,38 @@ dependencies = [ "truapi", ] +[[package]] +name = "truapi-host-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bip39", + "blake2-rfc", + "clap", + "frame-metadata", + "futures", + "futures-util", + "hex", + "parity-scale-codec", + "reqwest", + "rustls", + "scale-info", + "serde", + "serde_json", + "sp-crypto-hashing", + "subxt-rpcs", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "truapi", + "truapi-platform", + "truapi-server", + "verifiable", +] + [[package]] name = "truapi-macros" version = "0.1.0" @@ -3273,6 +4427,7 @@ dependencies = [ "truapi", "unicode-normalization", "url", + "zeroize", ] [[package]] @@ -3282,12 +4437,12 @@ dependencies = [ "aes-gcm", "async-trait", "blake2-rfc", - "bs58", + "blake2b_simd", "console_error_panic_hook", "derive_more 2.1.1", + "frame-metadata", "futures", "futures-timer", - "futures-util", "getrandom 0.2.17", "hex", "hkdf", @@ -3295,8 +4450,7 @@ dependencies = [ "nanoid", "p256", "parity-scale-codec", - "pin-project", - "primitive-types", + "scale-info", "schnorrkel", "send_wrapper 0.6.0", "serde", @@ -3304,6 +4458,7 @@ dependencies = [ "sha2 0.10.9", "sp-crypto-hashing", "substrate-bip39", + "subxt", "subxt-rpcs", "thiserror 1.0.69", "tracing", @@ -3312,6 +4467,7 @@ dependencies = [ "truapi-platform", "unicode-normalization", "url", + "verifiable", "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test", @@ -3320,6 +4476,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "twox-hash" version = "1.6.3" @@ -3328,6 +4510,7 @@ checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ "cfg-if", "digest 0.10.7", + "rand 0.8.6", "static_assertions", ] @@ -3392,6 +4575,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" @@ -3410,6 +4599,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3422,12 +4617,83 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "verifiable" +version = "0.5.0" +source = "git+https://github.com/paritytech/verifiable?rev=f65b39df04f2f9a453d78550438b189c96785285#f65b39df04f2f9a453d78550438b189c96785285" +dependencies = [ + "ark-scale", + "ark-serialize", + "ark-vrf", + "bounded-collections", + "parity-scale-codec", + "scale-info", + "sha2 0.10.9", + "smallvec", + "spin", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "w3f-pcs" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea1046a1deb6d26c34ba2d1f1bab4222d695d126502ee765f80b021753cb674" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "merlin", + "rayon", +] + +[[package]] +name = "w3f-plonk-common" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30408cda37b81bd7257319942584c794c5784d00d749757bc664656749a1472a" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "getrandom_or_panic", + "rand_core 0.6.4", + "rayon", + "w3f-pcs", +] + +[[package]] +name = "w3f-ring-proof" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cbfc4cb881a934e6f33c25927bf955d0cb18e52b94528bbc5fa28dddedb4cd1" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "ark-transcript", + "rayon", + "w3f-pcs", + "w3f-plonk-common", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -3438,6 +4704,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3678,6 +4953,24 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -3975,11 +5268,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "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/Cargo.toml b/Cargo.toml index 042aff82..1fbe8141 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,3 +5,11 @@ members = ["rust/crates/*"] [workspace.package] edition = "2024" license = "MIT" + +[profile.release] +opt-level = "z" +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = "symbols" +debug = false diff --git a/Makefile b/Makefile index c8bc371c..5a7acccc 100644 --- a/Makefile +++ b/Makefile @@ -3,59 +3,154 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check playground dev matrix explorer +.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test dev dev-bootstrap dev-link-check e2e-dotli headless install matrix explorer TRUAPI_PKG := js/packages/truapi PLAYGROUND := playground +JS_PACKAGES := js/packages EXPLORER := explorer DOTLI := hosts/dotli +HOST_WASM_PKG := $(JS_PACKAGES)/truapi-host +HOST_CALLBACKS_GENERATED := $(HOST_WASM_PKG)/src/generated/host-callbacks.ts +HOST_WASM_ADAPTER_GENERATED := $(HOST_WASM_PKG)/src/generated/host-callbacks-adapter.ts +HOST_WASM_WORKER_CALLBACKS_GENERATED := $(HOST_WASM_PKG)/src/generated/worker-callbacks.ts +HOST_WASM_WEB := $(HOST_WASM_PKG)/dist/wasm/web/truapi_server.js +DOTLI_UI := $(DOTLI)/packages/ui +DOTLI_HOST_WASM_LINK := $(DOTLI_UI)/node_modules/@parity/truapi-host +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 +export VITE_NETWORKS -# `make dev DEBUG=1` runs dotli with VITE_APP_DEBUG=true to log every wire frame. -DOTLI_PREVIEW := preview -ifdef DEBUG -DOTLI_PREVIEW := preview:debug -endif +# Local product URLs (`http://localhost:5173/localhost:3000`) are intentionally +# gated behind dotli's debug build flag, so the dev target must run the debug +# preview by default. Override with `DOTLI_PREVIEW=preview` to test production +# preview behavior. +DOTLI_PREVIEW ?= preview:debug +TRUAPI_WASM_PROFILE ?= dev +E2E_DOTLI_WASM_PROFILE ?= release help: ## Show this help. @awk 'BEGIN { FS = ":.*##"; printf "Usage: make \n\nTargets:\n" } \ - /^[a-zA-Z_-]+:.*?##/ { printf " %-12s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + /^[a-zA-Z0-9_-]+:.*?##/ { printf " %-12s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) -setup: ## First-time setup: submodules + JS dependencies. +setup: ## First-time setup: submodules, JS dependencies, generated artifacts. git submodule update --init --recursive - cd $(TRUAPI_PKG) && npm install + # --ignore-scripts: the workspace `prepare` builds need generated sources + # that only exist after codegen.sh, which also builds the packages. + npm ci --ignore-scripts + ./scripts/codegen.sh cd $(PLAYGROUND) && yarn install --frozen-lockfile + cd $(DOTLI) && bun install --frozen-lockfile build: ## Build the Rust workspace and the TypeScript client. cargo build --workspace cd $(TRUAPI_PKG) && npm run build + cd $(HOST_WASM_PKG) && npm run build + +headless: ## Build everything the headless-host e2e needs; then run rust/crates/truapi-host-cli/e2e/run.sh. + cargo build -p truapi-host-cli + cd $(TRUAPI_PKG) && npm run build + +install: headless ## Install the truapi-host CLI into Cargo's bin dir; use as `make headless install`. + cargo install --path rust/crates/truapi-host-cli --bin truapi-host --locked --force -codegen: ## Regenerate the TypeScript client from the Rust crate. +codegen: ## Regenerate generated TS/Rust artifacts from the Rust crates. ./scripts/codegen.sh cd $(PLAYGROUND) && rm -rf node_modules/@parity && yarn install +wasm: ## Rebuild the truapi-server WASM artifacts under js/packages/truapi-host/dist/wasm/. + cd $(HOST_WASM_PKG) && npm run build:wasm + +wasm-crypto-test: ## Run crypto/vector tests on wasm32 via wasm-pack/node. + wasm-pack test --node rust/crates/truapi-server --test wasm_crypto_vectors --no-default-features + test: ## Run Rust + TypeScript client tests. cargo test --workspace cd $(TRUAPI_PKG) && npm test + cd $(JS_PACKAGES)/truapi-host && npm test check: ## Full verification suite (build, fmt, clippy, test, TS tests, playground build + lint). cargo build --workspace cargo +nightly fmt --check cargo clippy --workspace --all-targets --all-features -- -D warnings - cargo test --workspace + cargo test --workspace --all-features --all-targets cd $(TRUAPI_PKG) && npm run build && npm test + cd $(JS_PACKAGES)/truapi-host && npm install --no-fund --no-audit && npm test cd $(PLAYGROUND) && yarn build && yarn lint +clean: ## Remove local build/test artifacts without deleting dependencies. + cargo clean + rm -rf \ + $(TRUAPI_PKG)/dist \ + $(TRUAPI_PKG)/tsconfig.tsbuildinfo \ + $(HOST_WASM_PKG)/dist \ + $(HOST_WASM_PKG)/tsconfig.tsbuildinfo \ + $(PLAYGROUND)/.next \ + $(PLAYGROUND)/out \ + $(PLAYGROUND)/test-results \ + $(PLAYGROUND)/tsconfig.tsbuildinfo \ + $(PLAYGROUND)/tests/tsconfig.tsbuildinfo \ + $(DOTLI)/.turbo \ + $(DOTLI)/apps/host/dist \ + $(DOTLI)/apps/protocol/dist \ + $(DOTLI)/apps/sandbox/dist \ + $(DOTLI)/test-results + playground: ## Refresh the playground's @parity/truapi snapshot and rebuild. cd $(TRUAPI_PKG) && npm run build cd $(PLAYGROUND) && rm -rf node_modules/@parity && yarn install cd $(PLAYGROUND) && yarn build -dev: ## Start dotli host (:5173) + playground (:3000) together; open http://localhost:5173/localhost:3000. DEBUG=1 logs wire frames. +dev-bootstrap: ## Prepare ignored generated/build artifacts needed by dotli preview. + git submodule update --init --recursive + # --ignore-scripts: the workspace `prepare` builds need generated sources + # 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=$(TRUAPI_WASM_PROFILE) $(MAKE) wasm + cd $(PLAYGROUND) && yarn install --frozen-lockfile + cd $(DOTLI) && bun install --frozen-lockfile + $(MAKE) dev-link-check + +dev-link-check: ## Verify dotli can resolve the local @parity/truapi-host package. + @test -f "$(HOST_CALLBACKS_GENERATED)" || (echo "Missing generated host callbacks. Run: make codegen"; exit 1) + @test -f "$(HOST_WASM_ADAPTER_GENERATED)" || (echo "Missing generated host callbacks WASM adapter. Run: make codegen"; exit 1) + @test -f "$(HOST_WASM_WORKER_CALLBACKS_GENERATED)" || (echo "Missing generated host callbacks worker bridge. Run: make codegen"; exit 1) + @test -f "$(HOST_WASM_PKG)/dist/index.js" || (echo "Missing @parity/truapi-host dist. Run: npm run build --prefix $(HOST_WASM_PKG)"; exit 1) + @test -f "$(HOST_WASM_WEB)" || (echo "Missing @parity/truapi-host web WASM glue. Run: make wasm"; exit 1) + @test -e "$(DOTLI_HOST_WASM_LINK)/package.json" || (echo "dotli cannot resolve @parity/truapi-host. Run top-level: make dev"; exit 1) + cd $(DOTLI_UI) && bun -e 'await import("@parity/truapi-host"); await import("@parity/truapi-host/web");' + +dev: dev-bootstrap ## Start dotli host (:5173) + playground (:3000) together; open http://localhost:5173/localhost:3000. DEBUG=1 logs wire frames. @trap 'kill 0' EXIT; \ ( cd $(DOTLI) && bun run $(DOTLI_PREVIEW) ) & \ ( cd $(PLAYGROUND) && yarn dev ) & \ + ( until curl -fsS http://localhost:3000/ >/dev/null 2>&1; do sleep 1; done; curl -fsS http://localhost:3000/diagnostics >/dev/null 2>&1 || true ) & \ wait +e2e-dotli: ## Fully automated dotli + playground diagnosis e2e. Requires SIGNER_BOT_SVC_TOKEN unless E2E_DOTLI_SMOKE=1. + @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 [ "$$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; \ + TRUAPI_WASM_PROFILE=$(E2E_DOTLI_WASM_PROFILE) $(MAKE) dev-bootstrap; \ + cd $(PLAYGROUND) && bun tests/e2e/dotli-diagnosis.ts + matrix: ## Regenerate the host compatibility matrix from explorer/diagnosis-reports. cd $(EXPLORER) && npm run generate-matrix diff --git a/README.md b/README.md index a08d9483..78a44a51 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > The following is a prototype, reference implementation, and proof-of-concept. This open source code is provided for research, experimentation, and developer education only. This code has not been audited, is actively experimental, and may contain bugs, vulnerabilities, or incomplete features. Use at your own risk. -*The protocol that lets product webviews talk to their Polkadot host.* +_The protocol that lets product webviews talk to their Polkadot host._ [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](./LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/paritytech/truapi/ci.yml?branch=main&style=flat-square&label=ci)](https://github.com/paritytech/truapi/actions/workflows/ci.yml) @@ -57,14 +57,31 @@ rust/crates/ truapi/ Rust trait and type definitions (v01, v02) truapi-codegen/ rustdoc JSON to TypeScript client + Rust dispatcher truapi-macros/ #[wire(id = N)] proc-macro + truapi-platform/ Host syscall traits used by truapi-server (storage, navigation, consent, ...) + truapi-server/ Rust runtime that hosts implement: dispatcher, frames, SCALE, WASM surface + truapi-host-cli/ Headless pairing-host + signing-host CLIs that pair over the real People-chain statement store for local e2e (signing-bot replacement) js/packages/ - truapi/ @parity/truapi TypeScript client -playground/ Interactive Next.js playground (truapi-playground.dot) -hosts/dotli/ dotli host, vendored as a submodule -docs/ Design docs, RFCs, feature proposals -scripts/codegen.sh Regenerate the TS client from the Rust source + truapi/ @parity/truapi TypeScript client + truapi-host/ @parity/truapi-host: WASM-backed host runtime; entries `.` + (shared host types), `/web` (iframe + Web Worker), + `/worker-runtime` +playground/ Interactive Next.js playground (truapi-playground.dot) +hosts/dotli/ dotli host, vendored as a submodule +docs/ Design docs, RFCs, feature proposals +scripts/codegen.sh Regenerate the TS client from the Rust source ``` +### JS Host SDKs + +JS hosts integrate the Rust core through [`@parity/truapi-host`](js/packages/truapi-host), +a single package with tree-shakeable subpath entries: + +- `@parity/truapi-host` (the `.` entry) exposes shared host runtime types and generated callback contracts. +- `@parity/truapi-host/web` wires the WASM provider into a browser host: the iframe + MessageChannel handshake (`createIframeHost`) plus `createWebWorkerProvider`. +- `@parity/truapi-host/worker-runtime` is the Web Worker entrypoint so the WASM core can + run off the page main thread. + ## How it works 1. The protocol is defined as Rust traits in [`rust/crates/truapi/`](rust/crates/truapi/), with each method tagged `#[wire(id = N)]` for a stable byte-level dispatch table. Every method's doc comment must carry a ` ```ts ` example, which codegen extracts into the playground's EXAMPLE tab; the build fails if any method is missing one. @@ -80,9 +97,10 @@ Common tasks are wrapped in the top-level `Makefile`. Run `make help` for the fu ```bash make setup # submodules + JS dependencies -make build # Rust workspace + TypeScript client -make test # Rust + TypeScript client tests +make build # Rust workspace + TypeScript client + @parity/truapi-host +make test # Rust + TypeScript client + @parity/truapi-host tests make check # full suite: build, fmt, clippy, test, TS tests, playground build + lint +make wasm # rebuild truapi-server WASM artifacts under js/packages/truapi-host/dist/wasm/ ``` To run the playground locally: @@ -129,4 +147,3 @@ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for issue reports, feature proposals, a ## License [MIT](./LICENSE) - diff --git a/deny.toml b/deny.toml index 4fdc03c6..500c13c3 100644 --- a/deny.toml +++ b/deny.toml @@ -5,11 +5,33 @@ allow = [ "MIT", "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", "Unicode-3.0", "Unlicense", "Zlib", ] confidence-threshold = 0.8 + +# uniffi is MPL-2.0: file-level weak copyleft, consumed unmodified as a +# dependency, which MPL-2.0 permits without affecting the MIT outbound licence. +# Scoped per crate so MPL-2.0 stays disallowed everywhere else. +# +# verifiable is GPL-3.0-or-later with the Classpath exception, consumed as an +# unmodified library dependency for ring-VRF proof construction. Keep the +# exception scoped to that crate. +exceptions = [ + { name = "uniffi", allow = ["MPL-2.0"] }, + { name = "uniffi_bindgen", allow = ["MPL-2.0"] }, + { name = "uniffi_core", allow = ["MPL-2.0"] }, + { name = "uniffi_internal_macros", allow = ["MPL-2.0"] }, + { name = "uniffi_macros", allow = ["MPL-2.0"] }, + { name = "uniffi_meta", allow = ["MPL-2.0"] }, + { name = "uniffi_pipeline", allow = ["MPL-2.0"] }, + { name = "uniffi_udl", allow = ["MPL-2.0"] }, + { name = "verifiable", allow = ["GPL-3.0-or-later WITH Classpath-exception-2.0"] }, +] diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index adda268c..bc52db8b 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -1,9 +1,9 @@ -# Releasing `@parity/truapi` +# Releasing npm packages -The `@parity/truapi` npm package is published by +The `@parity/truapi` and `@parity/truapi-host` npm packages are published by [`paritytech/npm_publish_automation`](https://github.com/paritytech/npm_publish_automation). We never run `npm publish` locally or from a personal account; the -`Release` workflow in `.github/workflows/release.yml` packs the package +`Release` workflow in `.github/workflows/release.yml` packs the packages and dispatches the automation. Releases happen via a dedicated **release PR**. Nothing publishes @@ -31,11 +31,13 @@ npm run version-packages # consumes the changeset, bumps package.json + writ ``` The first command writes a markdown file under `.changeset/`; the second -consumes it, bumps `js/packages/truapi/package.json`, appends an entry to -`js/packages/truapi/CHANGELOG.md`, deletes the changeset file, and then -runs `scripts/sync-cargo-version.mjs` to bump -`rust/crates/truapi/Cargo.toml` to the same version. All three files -should appear in the resulting diff. +consumes it, bumps the selected package `package.json`, appends the package +`CHANGELOG.md`, deletes the changeset file, and then runs +`scripts/sync-cargo-version.mjs` to keep `rust/crates/truapi/Cargo.toml` +aligned with `js/packages/truapi/package.json`. A protocol release should +therefore include the `@parity/truapi` package, its changelog, and the Cargo +version. A host-runtime-only release can bump `@parity/truapi-host` without +changing the Rust crate version. ### 3. Open a release PR @@ -49,6 +51,7 @@ The PR title must start with `release:`. Convention: ``` release: @parity/truapi 0.1.1 +release: @parity/truapi-host 0.1.1 ``` ### 4. Get the PR reviewed and merged @@ -66,11 +69,14 @@ say); the tag-already-exists guard makes re-runs safe. On merge, CI runs as usual. When CI passes, the `Release` workflow: 1. Confirms the commit subject starts with `release:`. -2. Reads the new version from `js/packages/truapi/package.json` and - checks no `@parity/truapi@` tag exists yet (re-runs are - idempotent — they skip). -3. Builds the package, creates and pushes the tag, packs the tarball, - and dispatches to `npm_publish_automation`. +2. Reads package versions from `js/packages/truapi/package.json` and + `js/packages/truapi-host/package.json`. +3. Checks for `@parity/truapi@` and + `@parity/truapi-host@` tags. Packages whose tag already exists + are skipped, so re-runs are idempotent. +4. Builds generated sources and the host WASM bundle, creates and pushes tags + for unpublished packages, packs their tarballs, and dispatches to + `npm_publish_automation`. You can watch the dispatched run under [`paritytech/npm_publish_automation` Actions](https://github.com/paritytech/npm_publish_automation/actions). @@ -79,7 +85,7 @@ You can watch the dispatched run under - A feature PR that accidentally bumps `package.json` will **not** trigger a publish — only `release:` PRs do. -- A `release:` PR that forgets to bump the version will be skipped at +- A `release:` PR that forgets to bump package versions will be skipped at the tag-already-exists check, not silently re-publish over an existing version. - A `release:` PR with mismatched `js/packages/truapi/package.json` and 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/docs/local-e2e-testing.md b/docs/local-e2e-testing.md index f646e2db..7ffde321 100644 --- a/docs/local-e2e-testing.md +++ b/docs/local-e2e-testing.md @@ -25,6 +25,21 @@ The chain below is also automated: The doc below is still the canonical narrative and the source of truth for failure modes — both the skills and CI cite it. +`make e2e-dotli` is the end-to-end dotli + playground diagnosis harness. It +starts the local dotli preview and playground, opens Chromium, signs out any +restored host session, signs in through the signer-bot SSO service, runs the +playground Diagnosis screen, and writes +`playground/test-results/e2e-dotli/diagnosis-report.md`. Full automation +requires `SIGNER_BOT_SVC_TOKEN`; `SIGNER_BOT_BASE_URL` and +`SIGNER_BOT_NETWORK` default to dotli CI's signer-bot service and +`paseo-next-v2`. Without the token, use +`E2E_DOTLI_SMOKE=1 make e2e-dotli` to verify the local stack, browser launch, +login click, TrUAPI debug logs, and QR/deeplink extraction without a phone. +In root CI, the job also needs `DOTLI_CHECKOUT_TOKEN` to read the private +dotli submodule. Without dotli access it reports a warning and skips the e2e +job; with dotli access but without `SIGNER_BOT_SVC_TOKEN`, it runs the smoke +path only. + The order matters: each layer assumes the layer below it builds clean. Skip a step only if you are certain the change cannot affect that layer. @@ -164,12 +179,19 @@ method from the UI. ```bash cd hosts/dotli bun run preview # → http://localhost:5173 -# or, for the TrUAPI debug panel: -bun run preview:debugger # = VITE_APP_DEBUG=true bun run preview ``` -`preview:debugger` is recommended whenever you're investigating a wire -issue — the debug panel logs every host↔product TrUAPI frame. +When investigating a wire issue, raise the Rust core's log level from the +host origin. The WASM worker bridge forwards core `tracing` output to the +browser console, mapping each level to the matching `console` method: + +```js +window.__truapi.setLogLevel("trace"); +``` + +`debug` and `trace` are emitted via `console.debug`, which Chrome hides +unless the console **Default levels ▾** dropdown includes **Verbose**; +`info`/`warn`/`error` always render. ### Start the playground dev server @@ -225,9 +247,9 @@ failing. Check: stale; redo step 4). If a method call hangs, the host either didn't receive the frame -(check dotli's debug panel or console) or didn't respond. The bridge -auto-responds to `host_handshake_request` only; everything else is on -the host implementation. +(check dotli's console with `truapi:logLevel` set to `debug`) or didn't respond. +The bridge auto-responds to `host_handshake_request` only; everything +else is on the host implementation. ## 7. Codegen tests diff --git a/docs/rfcs/0010-allowance.md b/docs/rfcs/0010-allowance.md index b8cd32b9..fe31710d 100644 --- a/docs/rfcs/0010-allowance.md +++ b/docs/rfcs/0010-allowance.md @@ -87,7 +87,7 @@ enum AllocatableResource { /// Allocate slot in SSS slot table StatementStoreAllowance, /// Allocate slot in Bulletin slot table - BulletInAllowance, + BulletinAllowance, /// Pre-claim PGAS into product account at `dest` to cover AH fees / storage deposits SmartContractAllowance { dest: DerivationIndex }, /// Grant auto-signing from product's own accounts. @@ -134,7 +134,7 @@ struct ResourceAllocationRequest { enum ApAllocatableResource { StatementStoreAllowance, - BulletInAllowance, + BulletinAllowance, SmartContractAllowance { dest: DerivationIndex }, AutoSigning, } @@ -162,7 +162,7 @@ enum ApAllocationOutcome { enum ApAllocatedResource { StatementStoreAllowance { slot_account_key: PrivateKey }, - BulletInAllowance { slot_account_key: PrivateKey }, + BulletinAllowance { slot_account_key: PrivateKey }, SmartContractAllowance, AutoSigning { /// Secret component of the soft-derivation path. @@ -252,7 +252,7 @@ sequenceDiagram participant B as Bulletin Chain P->>H: preimage_submit(chunk) - H->>A: request_resource_allocation(ProductId, [BulletInAllowance]) + H->>A: request_resource_allocation(ProductId, [BulletinAllowance]) A->>U: show authorization UI (single resource) U-->>A: approve A->>A: derive //allowance//bulletin//{productId} diff --git a/explorer/diagnosis-reports/headless-pairing.md b/explorer/diagnosis-reports/headless-pairing.md new file mode 100644 index 00000000..fb77bf8f --- /dev/null +++ b/explorer/diagnosis-reports/headless-pairing.md @@ -0,0 +1,68 @@ +## Truapi Headless Pairing Host Diagnosis + +| Method | Status | Details | +| --- | --- | --- | +| `Account/connection_status_subscribe` | ✅ | | +| `Account/get_account` | ✅ | | +| `Account/get_account_alias` | ✅ | | +| `Account/create_account_proof` | ⏭️ | | +| `Account/get_legacy_accounts` | ✅ | | +| `Account/get_user_id` | ✅ | | +| `Account/request_login` | ✅ | | +| `Chain/follow_head_subscribe` | ✅ | | +| `Chain/get_head_header` | ✅ | | +| `Chain/get_head_body` | ✅ | | +| `Chain/get_head_storage` | ✅ | | +| `Chain/call_head` | ✅ | | +| `Chain/unpin_head` | ✅ | | +| `Chain/continue_head` | ✅ | | +| `Chain/stop_head_operation` | ✅ | | +| `Chain/get_spec_genesis_hash` | ✅ | | +| `Chain/get_spec_chain_name` | ✅ | | +| `Chain/get_spec_properties` | ✅ | | +| `Chain/broadcast_transaction` | ✅ | | +| `Chain/stop_transaction` | ✅ | | +| `Chat/create_room` | ⏭️ | | +| `Chat/register_bot` | ⏭️ | | +| `Chat/list_subscribe` | ⏭️ | | +| `Chat/post_message` | ⏭️ | | +| `Chat/action_subscribe` | ⏭️ | | +| `Chat/custom_message_render_subscribe` | ⏭️ | | +| `Coin Payment/create_purse` | ⏭️ | | +| `Coin Payment/query_purse` | ⏭️ | | +| `Coin Payment/rebalance_purse` | ⏭️ | | +| `Coin Payment/delete_purse` | ⏭️ | | +| `Coin Payment/create_receivable` | ⏭️ | | +| `Coin Payment/create_cheque` | ⏭️ | | +| `Coin Payment/deposit` | ⏭️ | | +| `Coin Payment/refund` | ⏭️ | | +| `Coin Payment/listen_for_payment` | ⏭️ | | +| `Entropy/derive` | ✅ | | +| `Local Storage/read` | ✅ | | +| `Local Storage/write` | ✅ | | +| `Local Storage/clear` | ✅ | | +| `Notifications/send_push_notification` | ✅ | | +| `Notifications/cancel_push_notification` | ✅ | | +| `Payment/balance_subscribe` | ⏭️ | | +| `Payment/top_up` | ⏭️ | | +| `Payment/request` | ⏭️ | | +| `Payment/status_subscribe` | ⏭️ | | +| `Permissions/request_device_permission` | ✅ | | +| `Permissions/request_remote_permission` | ✅ | | +| `Preimage/lookup_subscribe` | ✅ | | +| `Preimage/submit` | ✅ | | +| `Resource Allocation/request` | ✅ | | +| `Signing/create_transaction` | ✅ | | +| `Signing/create_transaction_with_legacy_account` | ✅ | | +| `Signing/sign_raw_with_legacy_account` | ✅ | | +| `Signing/sign_payload_with_legacy_account` | ✅ | | +| `Signing/sign_raw` | ✅ | | +| `Signing/sign_payload` | ✅ | | +| `Statement Store/subscribe` | ✅ | | +| `Statement Store/create_proof` | ✅ | | +| `Statement Store/submit` | ✅ | | +| `Statement Store/create_proof_authorized` | ✅ | | +| `System/handshake` | ✅ | | +| `System/feature_supported` | ✅ | | +| `System/navigate_to` | ✅ | | +| `Theme/subscribe` | ✅ | | diff --git a/explorer/diagnosis-reports/headless-signing.md b/explorer/diagnosis-reports/headless-signing.md new file mode 100644 index 00000000..36667911 --- /dev/null +++ b/explorer/diagnosis-reports/headless-signing.md @@ -0,0 +1,68 @@ +## Truapi Headless Signing Host Diagnosis + +| Method | Status | Details | +| --- | --- | --- | +| `Account/connection_status_subscribe` | ✅ | | +| `Account/get_account` | ✅ | | +| `Account/get_account_alias` | ✅ | | +| `Account/create_account_proof` | ⏭️ | | +| `Account/get_legacy_accounts` | ✅ | | +| `Account/get_user_id` | ✅ | | +| `Account/request_login` | ✅ | | +| `Chain/follow_head_subscribe` | ✅ | | +| `Chain/get_head_header` | ✅ | | +| `Chain/get_head_body` | ✅ | | +| `Chain/get_head_storage` | ✅ | | +| `Chain/call_head` | ✅ | | +| `Chain/unpin_head` | ✅ | | +| `Chain/continue_head` | ✅ | | +| `Chain/stop_head_operation` | ✅ | | +| `Chain/get_spec_genesis_hash` | ✅ | | +| `Chain/get_spec_chain_name` | ✅ | | +| `Chain/get_spec_properties` | ✅ | | +| `Chain/broadcast_transaction` | ✅ | | +| `Chain/stop_transaction` | ✅ | | +| `Chat/create_room` | ⏭️ | | +| `Chat/register_bot` | ⏭️ | | +| `Chat/list_subscribe` | ⏭️ | | +| `Chat/post_message` | ⏭️ | | +| `Chat/action_subscribe` | ⏭️ | | +| `Chat/custom_message_render_subscribe` | ⏭️ | | +| `Coin Payment/create_purse` | ⏭️ | | +| `Coin Payment/query_purse` | ⏭️ | | +| `Coin Payment/rebalance_purse` | ⏭️ | | +| `Coin Payment/delete_purse` | ⏭️ | | +| `Coin Payment/create_receivable` | ⏭️ | | +| `Coin Payment/create_cheque` | ⏭️ | | +| `Coin Payment/deposit` | ⏭️ | | +| `Coin Payment/refund` | ⏭️ | | +| `Coin Payment/listen_for_payment` | ⏭️ | | +| `Entropy/derive` | ✅ | | +| `Local Storage/read` | ✅ | | +| `Local Storage/write` | ✅ | | +| `Local Storage/clear` | ✅ | | +| `Notifications/send_push_notification` | ✅ | | +| `Notifications/cancel_push_notification` | ✅ | | +| `Payment/balance_subscribe` | ⏭️ | | +| `Payment/top_up` | ⏭️ | | +| `Payment/request` | ⏭️ | | +| `Payment/status_subscribe` | ⏭️ | | +| `Permissions/request_device_permission` | ✅ | | +| `Permissions/request_remote_permission` | ✅ | | +| `Preimage/lookup_subscribe` | ✅ | | +| `Preimage/submit` | ✅ | | +| `Resource Allocation/request` | ✅ | | +| `Signing/create_transaction` | ✅ | | +| `Signing/create_transaction_with_legacy_account` | ✅ | | +| `Signing/sign_raw_with_legacy_account` | ✅ | | +| `Signing/sign_payload_with_legacy_account` | ✅ | | +| `Signing/sign_raw` | ✅ | | +| `Signing/sign_payload` | ✅ | | +| `Statement Store/subscribe` | ✅ | | +| `Statement Store/create_proof` | ✅ | | +| `Statement Store/submit` | ✅ | | +| `Statement Store/create_proof_authorized` | ✅ | | +| `System/handshake` | ✅ | | +| `System/feature_supported` | ✅ | | +| `System/navigate_to` | ✅ | | +| `Theme/subscribe` | ✅ | | diff --git a/explorer/diagnosis-reports/headless.md b/explorer/diagnosis-reports/headless.md new file mode 100644 index 00000000..d3c5564d --- /dev/null +++ b/explorer/diagnosis-reports/headless.md @@ -0,0 +1,68 @@ +## Truapi Headless Pairing Host Diagnosis + +| Method | Status | Details | +| --- | --- | --- | +| `Account/connection_status_subscribe` | ✅ | connection status: Connected | +| `Account/get_account` | ✅ | account retrieved: { "account": { "publicKey": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13" } } other product account retrieved after approval: { "account": { "publicKey": "0x54931f317cc49b2448a7fd1c1bc816b038065eac9c19a8c53fe6cff21419560d" } } | +| `Account/get_account_alias` | ✅ | account alias: { "context": "0x05cc3451e5a525ff21f0a62ffe5e5c184fa4cd790bbaead4ef44ab8dc914ecee", "alias": "0x34cc10180a6fc6977f179b03ae39a261ae5cd44fab26d2086545e00b1b94368c" } | +| `Account/create_account_proof` | ⏭️ | | +| `Account/get_legacy_accounts` | ✅ | legacy accounts: { "accounts": [ { "publicKey": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13", "name": "pgherveou.06" } ] } | +| `Account/get_user_id` | ✅ | user id: { "primaryUsername": "pgherveouu" } | +| `Account/request_login` | ✅ | login completed: AlreadyConnected | +| `Chain/follow_head_subscribe` | ✅ | head follow event: { "tag": "Initialized", "value": { "finalizedBlockHashes": [ "0x24a8038e7cc8a27d6fec2ae9b77b16785a1d1c794c6a365fac0741c352262364", "0x46c922275f522cf267838ffefde7b9a06390e798c306cb84c866e5bde6d55c4c", "0xdc7034d48de1f314b6bff28cd8c20600f45426188720d99bba5a058a63a2cb03", "0x43c1... | +| `Chain/get_head_header` | ✅ | block header: { "header": "0xd7a5dcde73a638f6cf32ddf9742e8ed344f3d74a60238baee743f2afdd6a138d22506200d0ed8a003223ddb768e65599cb3c356a59f42b2cf9c705dab8fff471b7fe013837993da375488a851df8efd0f70c3ea683246f565d680046a0feddecb481c37c1006434d4c53100101010c0661757261202930dc0800000000045250535290018a90... | +| `Chain/get_head_body` | ✅ | block body: { "operation": { "tag": "Started", "value": { "operationId": "0" } } } | +| `Chain/get_head_storage` | ✅ | storage value: { "operation": { "tag": "Started", "value": { "operationId": "0" } } } | +| `Chain/call_head` | ✅ | runtime call result: { "operation": { "tag": "Started", "value": { "operationId": "0" } } } | +| `Chain/unpin_head` | ✅ | blocks unpinned | +| `Chain/continue_head` | ✅ | operation continued | +| `Chain/stop_head_operation` | ✅ | operation stopped | +| `Chain/get_spec_genesis_hash` | ✅ | genesis hash: { "genesisHash": "0xbf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f" } | +| `Chain/get_spec_chain_name` | ✅ | chain name: { "chainName": "Paseo Asset Hub Next" } | +| `Chain/get_spec_properties` | ✅ | chain properties: { "properties": "{\"tokenDecimals\":10,\"tokenSymbol\":\"PAS\"}" } | +| `Chain/broadcast_transaction` | ✅ | transaction broadcast: { "operationId": "kVxLWAWJZIuYd3KM" } | +| `Chain/stop_transaction` | ❌ | stopTransaction failed: { "error": { "tag": "HostFailure", "value": { "reason": "remote_chain_transaction_stop: User error: Invalid operation id (-32602)" } } } | +| `Chat/create_room` | ⏭️ | | +| `Chat/register_bot` | ⏭️ | | +| `Chat/list_subscribe` | ⏭️ | | +| `Chat/post_message` | ⏭️ | | +| `Chat/action_subscribe` | ⏭️ | | +| `Chat/custom_message_render_subscribe` | ⏭️ | | +| `Coin Payment/create_purse` | ⏭️ | | +| `Coin Payment/query_purse` | ⏭️ | | +| `Coin Payment/rebalance_purse` | ⏭️ | | +| `Coin Payment/delete_purse` | ⏭️ | | +| `Coin Payment/create_receivable` | ⏭️ | | +| `Coin Payment/create_cheque` | ⏭️ | | +| `Coin Payment/deposit` | ⏭️ | | +| `Coin Payment/refund` | ⏭️ | | +| `Coin Payment/listen_for_payment` | ⏭️ | | +| `Entropy/derive` | ✅ | entropy derived: { "entropy": "0x9ad6f1f6ac64687863a3456a7ddcb06a94c0b9a950930bb8eea3b51743f70baa" } | +| `Local Storage/read` | ✅ | storage value read: | +| `Local Storage/write` | ✅ | storage write succeeded | +| `Local Storage/clear` | ✅ | storage clear succeeded | +| `Notifications/send_push_notification` | ✅ | notification sent: { "id": 1 } | +| `Notifications/cancel_push_notification` | ✅ | notification cancelled | +| `Payment/balance_subscribe` | ⏭️ | | +| `Payment/top_up` | ⏭️ | | +| `Payment/request` | ⏭️ | | +| `Payment/status_subscribe` | ⏭️ | | +| `Permissions/request_device_permission` | ✅ | device permission result: { "granted": true } | +| `Permissions/request_remote_permission` | ✅ | remote permission result: { "granted": true } | +| `Preimage/lookup_subscribe` | ❌ | submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "bulletin allowance is not available" } } } } } | +| `Preimage/submit` | ❌ | submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "bulletin allowance is not available" } } } } } | +| `Resource Allocation/request` | ✅ | resource allocation result: { "outcomes": [ "Allocated", "NotAvailable" ] } | +| `Signing/create_transaction` | ✅ | transaction created: { "transaction": "0xd9018400f41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef1301ae9c35d29363cd548f24c435c1a71af4971b119f32e376af9dc15e658d29c70e65baa9dd8430ecb7eee566f821ab87aba43d39821174de067478e45dff81c58a00000000000000000000000000000000000000" } | +| `Signing/create_transaction_with_legacy_account` | ✅ | selected legacy account: { "publicKey": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13", "name": "pgherveou.06" } transaction created: { "transaction": "0xd9018400f41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13012430b825adcbb1321b1310144a110809ae758cbc66b94d0... | +| `Signing/sign_raw_with_legacy_account` | ✅ | raw bytes signed: { "signature": "0x3af5d72c406e9279316f5ee707f4c320717ec72a55eb1ab80a04c36708079b3c486fc9102153c0dc5093d2630974302dc1ad6d129fe7024a137836a7331b8980" } | +| `Signing/sign_payload_with_legacy_account` | ✅ | payload signed: { "signature": "0x0061084213f1c30f5776f34ae6a84b14d5b1c3fc6ffca3b531986ae5456be523462d23f3da45e4f8c389c0bd55af3c3099be25da32bce68a8c5d605ccc1a4086" } | +| `Signing/sign_raw` | ✅ | raw bytes signed: { "signature": "0xd0afe46e7df6c2e032c3cb2fd923f96519aee31dda922770beba20c9d09e66389307bb1da61c53ea394e6babee235c852c1cd1a4af1d208153cdc06c767e2686" } | +| `Signing/sign_payload` | ✅ | payload signed: { "signature": "0x1eb6986df46f3347fb3054f8f2864f40eac4f03d43a05b2529a5b8e30fed1b4591da6ea79da89baf056bf4e94071d635ae0c32c5554d899d77cbe7e6a8d73986" } | +| `Statement Store/subscribe` | ✅ | submitting statement: { "expiry": "7661629886880022528n", "topics": [ "0xa962ad4e75e2fc9251c2fb585f98c83a69db2ce0cb99bbb7e0714591bdd9b1a6" ], "proof": { "tag": "Sr25519", "value": { "signature": "0xe6ec7fcffd0322d0bedc33ef99a491d1c6fd5a29b197d337e9bbb984309e544260098b3954cc936628f5deb82c51f5a95cc... | +| `Statement Store/create_proof` | ✅ | proof created: { "proof": { "tag": "Sr25519", "value": { "signature": "0x92b74641cd174206267af9a8bf11d09a20ab0a37924acc2018c386586b666f1e168831f7e126da6a139b7ef0809b2c286c7e89d4d7c5ff1cc800286f1b819486", "signer": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13" } } } | +| `Statement Store/submit` | ✅ | statement submitted | +| `Statement Store/create_proof_authorized` | ✅ | proof created: { "proof": { "tag": "Sr25519", "value": { "signature": "0x0e5ada3138607a38cf64e894cf50ccb2523b0de19a77f8bd7f984c39e6d71f38bc46b2fc7d2215610039f211f31e01bbf78b19d4caf25d7a8b481f2148bd618c", "signer": "0x60c724a9f56cb147403ae2ac1e7b55170ec8c29e127edc72dfb239431b03a379" } } } | +| `System/handshake` | ✅ | handshake succeeded | +| `System/feature_supported` | ✅ | feature supported: true | +| `System/navigate_to` | ✅ | navigation succeeded | +| `Theme/subscribe` | ✅ | theme received: { "name": { "tag": "Default" }, "variant": "Dark" } | diff --git a/hosts/dotli b/hosts/dotli index 80bedceb..df79ee14 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 80bedceb98bd6f305f5bf134c0225c203752ecac +Subproject commit df79ee147041a1b0db71e5773a9bc3aa3f8ac179 diff --git a/js/packages/truapi-host/.gitignore b/js/packages/truapi-host/.gitignore new file mode 100644 index 00000000..288deac9 --- /dev/null +++ b/js/packages/truapi-host/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +*.tsbuildinfo +# Ignore compiled TS output (top-level + the web/ and electron/ entry subdirs) +# Generated WASM artifacts under dist/wasm/ are ignored by the repo root. +dist/**/*.js +dist/**/*.d.ts +dist/**/*.js.map +dist/**/*.d.ts.map +dist/generated/ +# Codegen output from truapi-codegen --platform-ts-output. +src/generated/ diff --git a/js/packages/truapi-host/LICENSE b/js/packages/truapi-host/LICENSE new file mode 100644 index 00000000..ad207e8a --- /dev/null +++ b/js/packages/truapi-host/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Parity Technologies + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md new file mode 100644 index 00000000..44e0cd0f --- /dev/null +++ b/js/packages/truapi-host/README.md @@ -0,0 +1,81 @@ +# @parity/truapi-host + +WASM-backed TrUAPI host runtime. It embeds the `truapi-server` Rust core (compiled to WASM) +behind a Web Worker provider, plus per-environment integration entry points. It is the +counterpart to the native Android/iOS host shells. + +## Entry points + +The package exposes tree-shakeable subpath exports — import only what your environment needs: + +| Import | Provides | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | +| `@parity/truapi-host` | Shared runtime types plus generated typed host callback contracts. | +| `@parity/truapi-host/web` | Browser pairing host: `createIframeHost` (iframe MessageChannel handshake) and `createWebWorkerPairingHostRuntime`. | +| `@parity/truapi-host/worker-runtime` | Web Worker entrypoint (import with your bundler's `?worker` suffix) so the WASM core runs off the page main thread. | +| `@parity/truapi-host/wasm/web` | The raw browser `wasm-bindgen` glue, if you need to instantiate the core yourself. | + +## Generated WASM artefacts + +The ignored bundle under `dist/wasm/web/` is built with host-owned chain access. +Hosts wire their JSON-RPC provider through `chainConnect`; if they omit it, +chain calls fail with the core's standard unavailable error. Release builds use +the workspace size-optimized Rust profile plus `wasm-opt -Oz`, validate that +debug/name/producers custom sections were stripped, and emit `.wasm.gz` and +`.wasm.br` sidecars for hosts that serve precompressed assets. + +Build them after editing `rust/crates/truapi-server` and before packaging, publishing, or running +tests that load the raw WASM bundle (requires `wasm-pack` on PATH): + +```bash +npm run build:wasm # or `make wasm` from the repo root +``` + +## Example — browser (Web Worker) + +```ts +import HostWorker from "@parity/truapi-host/worker-runtime?worker"; +import { createWebWorkerPairingHostRuntime } from "@parity/truapi-host/web"; + +const runtime = await createWebWorkerPairingHostRuntime( + new HostWorker(), + callbacks, + { + hostConfig, + }, +); + +const firstProvider = await runtime.createProvider({ productId: "first.dot" }); +const secondProvider = await runtime.createProvider({ + productId: "second.dot", +}); +``` + +`@parity/truapi-host/web` also exports `createIframeHost` for the +protocol-iframe MessageChannel handshake. Host code creates one worker runtime +and then opens one provider per product id. + +## Publishing + +This package is published by the root `Release` workflow through +`paritytech/npm_publish_automation`. Do not run `npm publish` locally. Cut a +`release:` PR with a changeset for `@parity/truapi-host`; the workflow builds +the generated host bindings, the browser WASM bundle, packs the tarball, and +publishes it when the `@parity/truapi-host@` tag does not already +exist. + +## Architecture + +```text +JS host code + protocol handlers / typed callbacks + (types from @parity/truapi-host) + | + v +createWebWorkerPairingHostRuntime + shared worker runtime: pairing session, chain runtime, WASM instance + | + +-- createProvider({ productId }) -> product core / WireProvider + | + +-- createProvider({ productId }) -> product core / WireProvider +``` diff --git a/js/packages/truapi-host/package.json b/js/packages/truapi-host/package.json new file mode 100644 index 00000000..f3988d74 --- /dev/null +++ b/js/packages/truapi-host/package.json @@ -0,0 +1,59 @@ +{ + "name": "@parity/truapi-host", + "version": "0.1.0", + "description": "WASM-backed TrUAPI host runtime: embeds the Rust core, with web iframe and Web Worker entry points", + "license": "MIT", + "author": "Parity Technologies ", + "repository": { + "type": "git", + "url": "git+https://github.com/paritytech/truapi.git", + "directory": "js/packages/truapi-host" + }, + "homepage": "https://github.com/paritytech/truapi#readme", + "bugs": { + "url": "https://github.com/paritytech/truapi/issues" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "sideEffects": [ + "./dist/worker-runtime.js", + "./dist/wasm/**" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./web": { + "types": "./dist/web/index.d.ts", + "import": "./dist/web/index.js" + }, + "./worker-runtime": { + "types": "./dist/worker-runtime.d.ts", + "import": "./dist/worker-runtime.js" + }, + "./wasm/web": { + "types": "./dist/wasm/web/truapi_server.d.ts", + "import": "./dist/wasm/web/truapi_server.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc -b", + "build:wasm": "node scripts/build-wasm.mjs", + "test": "bun test" + }, + "dependencies": { + "@parity/truapi": "file:../truapi" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "neverthrow": "^8.2.0", + "typescript": "^5.7" + } +} diff --git a/js/packages/truapi-host/scripts/build-wasm.mjs b/js/packages/truapi-host/scripts/build-wasm.mjs new file mode 100644 index 00000000..22f0c698 --- /dev/null +++ b/js/packages/truapi-host/scripts/build-wasm.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node +// Rebuild the browser truapi-server WASM artefacts generated under +// `dist/wasm/web/`. wasm-pack is required. + +import { execFile } from "node:child_process"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { + brotliCompress, + brotliDecompress, + constants as zlibConstants, + gzip, + gunzip, +} from "node:zlib"; + +const execFileAsync = promisify(execFile); +const brotliCompressAsync = promisify(brotliCompress); +const brotliDecompressAsync = promisify(brotliDecompress); +const gzipAsync = promisify(gzip); +const gunzipAsync = promisify(gunzip); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkgRoot = resolve(__dirname, ".."); +const repoRoot = resolve(pkgRoot, "../../.."); +const rustCrate = resolve(repoRoot, "rust/crates/truapi-server"); +const wasmProfile = process.env.TRUAPI_WASM_PROFILE ?? "release"; +const wasmFileName = "truapi_server_bg.wasm"; + +function args(target, outDir) { + const command = [ + "build", + "--target", + target, + "--out-dir", + outDir, + "--out-name", + "truapi_server", + ]; + if (wasmProfile === "dev") { + command.push("--dev"); + } else if (wasmProfile === "profiling") { + command.push("--profiling"); + } else if (wasmProfile !== "release") { + throw new Error( + `Unsupported TRUAPI_WASM_PROFILE=${wasmProfile}; expected release, dev, or profiling`, + ); + } + command.push(rustCrate, "--no-default-features"); + return command; +} + +function formatBytes(bytes) { + if (bytes < 1024) return `${bytes} B`; + const kib = bytes / 1024; + if (kib < 1024) return `${kib.toFixed(1)} KiB`; + return `${(kib / 1024).toFixed(2)} MiB`; +} + +function readVarUint(bytes, cursor) { + let result = 0; + let shift = 0; + let position = cursor; + while (position < bytes.length) { + const byte = bytes[position]; + result += (byte & 0x7f) * 2 ** shift; + position += 1; + if ((byte & 0x80) === 0) { + return [result, position]; + } + shift += 7; + } + throw new Error("unterminated wasm varuint"); +} + +function readCustomSectionNames(bytes) { + if ( + bytes.length < 8 || + bytes[0] !== 0x00 || + bytes[1] !== 0x61 || + bytes[2] !== 0x73 || + bytes[3] !== 0x6d + ) { + throw new Error("generated file is not a wasm module"); + } + + const names = []; + let offset = 8; + while (offset < bytes.length) { + const sectionId = bytes[offset]; + offset += 1; + const [sectionSize, payloadStart] = readVarUint(bytes, offset); + const payloadEnd = payloadStart + sectionSize; + if (payloadEnd > bytes.length) { + throw new Error("wasm section extends past end of file"); + } + if (sectionId === 0) { + const [nameLength, nameStart] = readVarUint(bytes, payloadStart); + const nameEnd = nameStart + nameLength; + if (nameEnd > payloadEnd) { + throw new Error("wasm custom section name extends past section end"); + } + names.push(Buffer.from(bytes.subarray(nameStart, nameEnd)).toString("utf8")); + } + offset = payloadEnd; + } + return names; +} + +async function validateReleaseWasm(wasmPath) { + if (wasmProfile !== "release") return; + + const wasm = await readFile(wasmPath); + const customSections = readCustomSectionNames(wasm); + const forbidden = customSections.filter( + (name) => name === "name" || name === "producers" || name.startsWith(".debug"), + ); + if (forbidden.length > 0) { + throw new Error( + `release wasm retained debug/metadata custom sections: ${forbidden.join(", ")}`, + ); + } +} + +async function writeCompressedSidecars(wasmPath) { + if (wasmProfile !== "release") return; + + const wasm = await readFile(wasmPath); + const gzipBytes = await gzipAsync(wasm, { level: 9 }); + const brotliBytes = await brotliCompressAsync(wasm, { + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: 11, + }, + }); + + await writeFile(`${wasmPath}.gz`, gzipBytes); + await writeFile(`${wasmPath}.br`, brotliBytes); + + const [gzipRoundTrip, brotliRoundTrip] = await Promise.all([ + gunzipAsync(gzipBytes), + brotliDecompressAsync(brotliBytes), + ]); + if (!gzipRoundTrip.equals(wasm) || !brotliRoundTrip.equals(wasm)) { + throw new Error("compressed wasm sidecar round-trip validation failed"); + } + + process.stdout.write( + [ + `wasm size: ${formatBytes(wasm.length)}`, + `gzip: ${formatBytes(gzipBytes.length)}`, + `brotli: ${formatBytes(brotliBytes.length)}`, + ].join(" | ") + "\n", + ); +} + +async function build(target, subdir) { + const outDir = resolve(pkgRoot, "dist/wasm", subdir); + process.stdout.write( + `wasm-pack build --target ${target} --${wasmProfile} → ${outDir}\n`, + ); + try { + await execFileAsync("wasm-pack", args(target, outDir), { cwd: repoRoot }); + } catch (err) { + if (err?.code === "ENOENT") { + console.error( + "wasm-pack is required. Install it with `cargo install wasm-pack` " + + "or see https://rustwasm.github.io/wasm-pack/installer/", + ); + process.exit(1); + } + throw err; + } + // wasm-pack writes a nested `.gitignore: *`; the repo-level ignore already + // owns generated WASM outputs. + await rm(resolve(outDir, ".gitignore"), { force: true }); + const wasmPath = resolve(outDir, wasmFileName); + await Promise.all([ + rm(`${wasmPath}.br`, { force: true }), + rm(`${wasmPath}.gz`, { force: true }), + ]); + await validateReleaseWasm(wasmPath); + await writeCompressedSidecars(wasmPath); +} + +await build("web", "web"); diff --git a/js/packages/truapi-host/src/adapter-support.ts b/js/packages/truapi-host/src/adapter-support.ts new file mode 100644 index 00000000..b2b999e7 --- /dev/null +++ b/js/packages/truapi-host/src/adapter-support.ts @@ -0,0 +1,125 @@ +// Hand-written runtime support for the generated `createWasmRawCallbacks` +// adapter (`./generated/host-callbacks-adapter.ts`). The adapter is mechanical +// (decode params, call the typed host callback, read the result); the pieces +// here are the genuinely bespoke runtime plumbing it leans on: stream driving +// and the chain-connection handle. + +import { type GenericError, type Result } from "@parity/truapi"; +import { hexToBytes } from "@parity/truapi/scale"; + +import type { ChainConnect, ChainConnection } from "./runtime.js"; +import type { ChainProvider } from "./generated/host-callbacks.js"; + +type WireResult = + | { success: true; value: T } + | { success: false; value: E }; + +type StreamResult = Result | WireResult; + +type MaybeAsyncIterable = AsyncIterable | Iterable; + +/** + * Normalize both generated `Result` values and the plain + * `{ success, value }` envelope used by some JS fixtures into a raw item. + */ +function unwrapStreamResult(item: StreamResult): T { + if ("success" in item) { + if (item.success === false) { + throw new Error(item.value.reason); + } + return item.value; + } + if (item.isErr()) { + throw new Error(item.error.reason); + } + return item.value; +} + +/** + * Accept sync and async host streams behind one async-iterator interface. + * Host callbacks often use async iterables in production, while tests can use + * small synchronous fixtures without a custom wrapper. + */ +function toAsyncIterator(stream: MaybeAsyncIterable): AsyncIterator { + const asyncIterable = stream as AsyncIterable; + if (typeof asyncIterable[Symbol.asyncIterator] === "function") { + return asyncIterable[Symbol.asyncIterator](); + } + const iterator = (stream as Iterable)[Symbol.iterator](); + const asyncIterator: AsyncIterator = { + next: async () => iterator.next(), + }; + if (iterator.return) { + asyncIterator.return = async () => iterator.return!(); + } + return asyncIterator; +} + +/** + * Drain an async iterator into a sink until disposed. This is used for + * callback streams where the Rust core owns cancellation but JS owns the + * iterator and any transport cleanup behind `return()`. + */ +function pumpIterator( + iterator: AsyncIterator, + onItem: (value: T) => void, + label: string, +): () => void { + let stopped = false; + void (async () => { + try { + while (!stopped) { + const next = await iterator.next(); + if (next.done) return; + onItem(next.value); + } + } catch (err) { + console.error(`[truapi host callbacks] ${label} failed:`, err); + } + })(); + return () => { + stopped = true; + void iterator.return?.(); + }; +} + +/** + * Drive a typed host stream of `Result` items into the core's `sendItem` + * sink, unwrapping each `Result` (or throwing on its error). Returns a + * disposer that stops iteration. + */ +export function driveResultStream( + stream: MaybeAsyncIterable>, + sendItem: (value: T) => void, +): () => void { + return pumpIterator( + toAsyncIterator(stream), + (value) => sendItem(unwrapStreamResult(value)), + "subscription", + ); +} + +/** + * Bridge the typed `ChainProvider.connect` callback onto the raw + * `chainConnect` the WASM core invokes: decode the genesis hash, pump the + * connection's `responses()` stream into `onResponse`, and expose + * `send`/`close`. + */ +export function chainConnectAdapter( + host: Pick, +): ChainConnect { + return async (genesisHash, onResponse): Promise => { + const connection = await host.connect(hexToBytes(genesisHash)); + const iterator = connection.responses()[Symbol.asyncIterator](); + const stopResponses = pumpIterator(iterator, onResponse, "chain responses"); + return { + send(request: string): void { + connection.send(request); + }, + close(): void { + stopResponses(); + connection.close(); + }, + }; + }; +} diff --git a/js/packages/truapi-host/src/error.ts b/js/packages/truapi-host/src/error.ts new file mode 100644 index 00000000..5dc5674e --- /dev/null +++ b/js/packages/truapi-host/src/error.ts @@ -0,0 +1,6 @@ +/** Coerce an unknown thrown value into a human-readable message string. */ +export function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + return JSON.stringify(err) ?? String(err); +} diff --git a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts new file mode 100644 index 00000000..b28cc4e2 --- /dev/null +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -0,0 +1,408 @@ +import { describe, expect, it } from "bun:test"; +import { ok } from "neverthrow"; + +import { + HostDevicePermissionRequest, + HostDevicePermissionResponse, + HostFeatureSupportedRequest, + HostFeatureSupportedResponse, + HostPushNotificationRequest, + HostPushNotificationResponse, + RemotePermissionRequest, + RemotePermissionResponse, + ThemeVariant, +} from "@parity/truapi"; +import type { HostSignPayloadData } from "@parity/truapi"; + +import { createWasmRawCallbacks } from "./generated/host-callbacks-adapter.js"; +import { + AuthState, + CoreStorageKey, + UserConfirmationReview, +} from "./generated/host-callbacks.js"; +import { makeHostCallbacks, settle } from "./test-support.js"; + +// The generated `createWasmRawCallbacks` adapter speaks the symmetric SCALE +// byte boundary: codec-typed requests arrive as `Uint8Array` and are decoded +// for the typed host callback; codec-typed responses are SCALE-encoded back to +// `Uint8Array`. Primitives, strings and byte blobs pass through unchanged. + +const GENESIS = `0x${"11".repeat(32)}` as `0x${string}`; +const PRODUCT_ACCOUNT = { + dotNsIdentifier: "playground.dot", + derivationIndex: 0, +}; +const SIGN_PAYLOAD: HostSignPayloadData = { + blockHash: GENESIS, + blockNumber: "0x01", + era: "0x00", + genesisHash: GENESIS, + method: "0x0102", + nonce: "0x00", + specVersion: "0x01", + tip: "0x00", + transactionVersion: "0x01", + signedExtensions: [], + version: 4, + assetId: undefined, + metadataHash: undefined, + mode: undefined, +}; + +describe("createWasmRawCallbacks", () => { + it("decodes requests and encodes typed responses", async () => { + const writes: [string, number[]][] = []; + const clears: string[] = []; + const cancelled: number[] = []; + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + notifications: { + pushNotification: async (notification) => ({ + id: notification.text.length, + }), + cancelNotification: async (id) => { + cancelled.push(id); + }, + }, + permissions: { + devicePermission: async (request) => ({ + granted: request === "Camera", + }), + remotePermission: async (request) => ({ + granted: request.permission.tag === "ChainSubmit", + }), + }, + features: { + featureSupported: async (request) => ({ + supported: + request.tag === "Chain" && request.value.genesisHash === GENESIS, + }), + }, + productStorage: { + read: async (key) => new TextEncoder().encode(`read:${key}`), + write: async (key, value) => { + writes.push([key, [...value]]); + }, + clear: async (key) => { + clears.push(key); + }, + }, + }), + ); + + expect( + HostPushNotificationResponse.dec( + await raw.pushNotification!( + HostPushNotificationRequest.enc({ + text: "hello", + deeplink: undefined, + scheduledAt: undefined, + }), + ), + ).id, + ).toBe(5); + expect( + HostDevicePermissionResponse.dec( + await raw.devicePermission!(HostDevicePermissionRequest.enc("Camera")), + ).granted, + ).toBe(true); + expect( + RemotePermissionResponse.dec( + await raw.remotePermission!( + RemotePermissionRequest.enc({ + permission: { tag: "ChainSubmit" }, + }), + ), + ).granted, + ).toBe(true); + expect( + HostFeatureSupportedResponse.dec( + await raw.featureSupported!( + HostFeatureSupportedRequest.enc({ + tag: "Chain", + value: { genesisHash: GENESIS }, + }), + ), + ).supported, + ).toBe(true); + expect(await raw.read!("session")).toEqual( + new TextEncoder().encode("read:session"), + ); + + await raw.write!("session", new Uint8Array([1, 2, 3])); + await raw.clear!("session"); + await raw.cancelNotification?.(9); + + expect(writes).toEqual([["session", [1, 2, 3]]]); + expect(clears).toEqual(["session"]); + expect(cancelled).toEqual([9]); + }); + + it("bridges lifecycle, confirmations, and preimage callbacks", async () => { + const calls: unknown[][] = []; + async function* preimages() { + yield ok(undefined); + yield ok(new Uint8Array([4, 5, 6])); + } + + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + auth: { + authStateChanged: (state) => { + calls.push(["authStateChanged", state]); + }, + }, + coreStorage: { + readCoreStorage: async (key) => + key.tag === "AuthSession" ? new Uint8Array([1, 2, 3]) : undefined, + writeCoreStorage: async (key, value) => { + calls.push(["writeCoreStorage", key, [...value]]); + }, + clearCoreStorage: async (key) => { + calls.push(["clearCoreStorage", key]); + }, + }, + userConfirmation: { + confirmUserAction: async (review) => { + switch (review.tag) { + case "SignPayload": + return ( + review.value.tag === "Product" && + review.value.value.account.dotNsIdentifier === + "playground.dot" && + review.value.value.payload.method === "0x0102" + ); + case "SignRaw": + return ( + review.value.tag === "Product" && + review.value.value.payload.tag === "Bytes" && + review.value.value.payload.value.bytes === "0x0304" + ); + case "CreateTransaction": + return ( + review.value.tag === "Product" && + review.value.value.signer.derivationIndex === 0 && + review.value.value.callData === "0x0506" + ); + case "AccountAlias": + return ( + review.value.requestingProductId === "playground.dot" && + review.value.targetProductId === "wallet.dot" + ); + case "AccountAccess": + return ( + review.value.requestingProductId === "playground.dot" && + review.value.targetProductId === "wallet.dot" + ); + case "ResourceAllocation": + return ( + review.value.resources[0]?.tag === "StatementStoreAllowance" + ); + case "PreimageSubmit": + calls.push([ + "confirmUserAction:PreimageSubmit", + review.value.size, + ]); + return review.value.size === 42n; + } + }, + }, + preimage: { + lookupPreimage: (key) => { + calls.push(["lookupPreimage", [...key]]); + return preimages(); + }, + }, + }), + ); + + const preimageEvents: (number[] | null)[] = []; + const disposePreimages = raw.lookupPreimage!(new Uint8Array([9]), (value) => + preimageEvents.push(value ? [...value] : null), + ); + + raw.authStateChanged?.( + AuthState.enc({ + tag: "Pairing", + value: { deeplink: "polkadotapp://example" }, + }), + ); + const authSessionKey = CoreStorageKey.enc({ tag: "AuthSession" }); + expect(await raw.readCoreStorage!(authSessionKey)).toEqual( + new Uint8Array([1, 2, 3]), + ); + await raw.writeCoreStorage!(authSessionKey, new Uint8Array([3, 2, 1])); + await raw.clearCoreStorage!(authSessionKey); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "SignPayload", + value: { + tag: "Product", + value: { + account: PRODUCT_ACCOUNT, + payload: SIGN_PAYLOAD, + }, + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "SignRaw", + value: { + tag: "Product", + value: { + account: PRODUCT_ACCOUNT, + payload: { + tag: "Bytes", + value: { bytes: "0x0304" }, + }, + }, + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "CreateTransaction", + value: { + tag: "Product", + value: { + signer: PRODUCT_ACCOUNT, + genesisHash: GENESIS, + callData: "0x0506", + extensions: [], + txExtVersion: 0, + }, + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "AccountAlias", + value: { + requestingProductId: "playground.dot", + targetProductId: "wallet.dot", + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "AccountAccess", + value: { + requestingProductId: "playground.dot", + targetProductId: "wallet.dot", + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "ResourceAllocation", + value: { + resources: [{ tag: "StatementStoreAllowance" }], + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "PreimageSubmit", + value: { size: 42n }, + }), + ), + ).toBe(true); + + await settle(); + await settle(); + + expect(preimageEvents).toEqual([null, [4, 5, 6]]); + expect(calls).toEqual([ + ["lookupPreimage", [9]], + [ + "authStateChanged", + { tag: "Pairing", value: { deeplink: "polkadotapp://example" } }, + ], + ["writeCoreStorage", { tag: "AuthSession", value: undefined }, [3, 2, 1]], + ["clearCoreStorage", { tag: "AuthSession", value: undefined }], + ["confirmUserAction:PreimageSubmit", 42n], + ]); + + disposePreimages?.(); + }); + + it("adapts typed result subscriptions", async () => { + async function* themes() { + yield ok("Dark"); + yield ok("Light"); + } + + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + theme: { + subscribeTheme: () => themes(), + }, + }), + ); + const seen: ThemeVariant[] = []; + const dispose = raw.subscribeTheme?.((theme) => + seen.push(ThemeVariant.dec(theme!)), + ); + + await settle(); + await settle(); + + expect(seen).toEqual(["Dark", "Light"]); + dispose?.(); + }); + + it("bridges typed chain connections", async () => { + const sent: string[] = []; + const responses = ['{"jsonrpc":"2.0","id":1,"result":"ok"}']; + let closes = 0; + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + chain: { + connect: async (genesisHash) => { + expect([...genesisHash]).toEqual(Array(32).fill(0x11)); + return { + send(request) { + sent.push(request); + }, + async *responses() { + yield* responses; + }, + close() { + closes += 1; + }, + }; + }, + }, + }), + ); + + expect(typeof raw.chainConnect).toBe("function"); + const received: string[] = []; + const connection = await raw.chainConnect!(GENESIS, (json) => + received.push(json), + ); + expect(connection).toBeTruthy(); + + connection!.send('{"jsonrpc":"2.0","id":1,"method":"system_health"}'); + await settle(); + + expect(sent).toEqual(['{"jsonrpc":"2.0","id":1,"method":"system_health"}']); + expect(received).toEqual(responses); + connection!.close(); + expect(closes).toBe(1); + }); +}); diff --git a/js/packages/truapi-host/src/index.ts b/js/packages/truapi-host/src/index.ts new file mode 100644 index 00000000..b3744f17 --- /dev/null +++ b/js/packages/truapi-host/src/index.ts @@ -0,0 +1,3 @@ +export type { Payload, ProtocolMessage, WireProvider } from "@parity/truapi"; + +export * from "./runtime.js"; diff --git a/js/packages/truapi-host/src/runtime.ts b/js/packages/truapi-host/src/runtime.ts new file mode 100644 index 00000000..7e06329e --- /dev/null +++ b/js/packages/truapi-host/src/runtime.ts @@ -0,0 +1,89 @@ +import type { WireProvider } from "@parity/truapi"; +import { CoreStorageKey as GeneratedCoreStorageKey } from "./generated/host-callbacks.js"; +import type { + CoreAdmin, + CoreStorageKey, +} from "./generated/host-callbacks.js"; + +// The typed capability interfaces below come straight from the +// `truapi-platform` Rust crate via `truapi-codegen --platform-ts-output`. +// They are the host-author-facing surface: each method takes/returns +// typed wrappers (`HostDevicePermissionRequest`, etc.) rather than raw +// SCALE bytes. The web worker pairing-host runtime adapts this typed surface +// into the byte-oriented callback bridge consumed by the WASM core. +export * from "./generated/host-callbacks.js"; +export type { + JsonRpcConnection as PlatformJsonRpcConnection, +} from "./generated/host-callbacks.js"; + +/** Encode a typed core-storage slot for hosts that need an opaque backing key. */ +export function encodeCoreStorageKey(key: CoreStorageKey): Uint8Array { + return GeneratedCoreStorageKey.enc(key); +} + +/** + * Async-or-sync return. Synchronous hosts (e.g. the dotli main-thread + * shell hitting localStorage) can return a plain value; the WASM bridge + * awaits every return so an `async` impl also works. + */ +export type Awaitable = T | Promise; + +/** + * Open a JSON-RPC connection for `genesisHash`. The wasm bridge passes + * `onResponse` so the host can push JSON-RPC replies back asynchronously. + * Returning `null` (or throwing) tells the core no provider is available. + */ +export type ChainConnect = ( + genesisHash: string, + onResponse: (json: string) => void, +) => Awaitable; + +/** + * Per-connection handle returned by `chainConnect`. `send` forwards a + * SCALE-encoded JSON-RPC request; `close` tears the connection down. + */ +export interface ChainConnection { + send(request: string): void; + close(): void; +} + +/** + * Verbosity threshold for the wasm core's `tracing` output. The Rust core + * parses the string; known values are `off`, `error`, `warn`, `info`, `debug`, + * and `trace`. + */ +export type LogLevel = string; + +export interface ProductRuntimeConfig { + productId: string; + host: { + name: string; + icon?: string; + version?: string; + }; + platform?: { + type?: string; + version?: string; + }; + people: { + genesisHash: string | Uint8Array; + }; + /** + * Bulletin-chain genesis hash used for in-core preimage submission. + */ + bulletin: { + genesisHash: string | Uint8Array; + }; + pairing: { + deeplinkScheme: string; + }; +} + +export interface TrUApiProductProvider extends WireProvider, CoreAdmin { + /** + * Re-tune the wasm core's log level at runtime. Present on runtimes that + * keep a live channel to the core (e.g. the Web Worker provider); absent on + * one-shot constructions that only accept `logLevel` up front. + */ + setLogLevel?(level: LogLevel): void; +} diff --git a/js/packages/truapi-host/src/test-support.ts b/js/packages/truapi-host/src/test-support.ts new file mode 100644 index 00000000..f6e0240b --- /dev/null +++ b/js/packages/truapi-host/src/test-support.ts @@ -0,0 +1,84 @@ +import type { RequiredHostCallbacks } from "./generated/host-callbacks.js"; + +/** `HostCallbacks` with every optional member required, for exhaustive test fixtures. */ +export type CompleteHostCallbacks = RequiredHostCallbacks; + +type HostCallbackOverrides = { + [K in keyof RequiredHostCallbacks]?: Partial; +}; + +/** Default no-op host callbacks with optional per-test overrides. */ +export function makeHostCallbacks( + overrides: HostCallbackOverrides = {}, +): CompleteHostCallbacks { + const defaults: CompleteHostCallbacks = { + navigation: { navigateTo: async () => {} }, + notifications: { + pushNotification: async () => ({ id: 0 }), + cancelNotification: async () => {}, + }, + permissions: { + devicePermission: async () => ({ granted: false }), + remotePermission: async () => ({ granted: false }), + }, + features: { featureSupported: async () => ({ supported: false }) }, + productStorage: { + read: async () => undefined, + write: async () => {}, + clear: async () => {}, + }, + coreStorage: { + readCoreStorage: async () => undefined, + writeCoreStorage: async () => {}, + clearCoreStorage: async () => {}, + }, + auth: { authStateChanged: () => {} }, + userConfirmation: { confirmUserAction: async () => false }, + preimage: { + submitPreimage: async () => new Uint8Array(), + async *lookupPreimage() {}, + }, + theme: { async *subscribeTheme() {} }, + chain: { + connect: async () => ({ + send() {}, + async *responses() {}, + close() {}, + }), + }, + }; + + return { + navigation: { ...defaults.navigation, ...overrides.navigation }, + notifications: { + ...defaults.notifications, + ...overrides.notifications, + }, + permissions: { + ...defaults.permissions, + ...overrides.permissions, + }, + features: { ...defaults.features, ...overrides.features }, + productStorage: { + ...defaults.productStorage, + ...overrides.productStorage, + }, + coreStorage: { + ...defaults.coreStorage, + ...overrides.coreStorage, + }, + auth: { ...defaults.auth, ...overrides.auth }, + userConfirmation: { + ...defaults.userConfirmation, + ...overrides.userConfirmation, + }, + preimage: { ...defaults.preimage, ...overrides.preimage }, + theme: { ...defaults.theme, ...overrides.theme }, + chain: { ...defaults.chain, ...overrides.chain }, + }; +} + +/** Resolve after the current microtask/immediate queue, letting pending async work run. */ +export function settle(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} diff --git a/js/packages/truapi-host/src/web/create-iframe-host.test.ts b/js/packages/truapi-host/src/web/create-iframe-host.test.ts new file mode 100644 index 00000000..9a9108bc --- /dev/null +++ b/js/packages/truapi-host/src/web/create-iframe-host.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; + +import { createIframeHost } from "./index.js"; + +// Verify that `createIframeHost` hands a MessagePort back through `onPort`, +// constructs an iframe with the expected attributes, and posts the +// `truapi-init` handshake after the iframe reports readiness. + +function setupFakeDom() { + // Track listeners on the synthetic `window` and the iframe so the + // test can simulate the iframe `load` event after construction. + const iframeListeners = new Map void>(); + const windowListeners = new Map void>(); + const windowRemove = mock((_name: string, _fn: unknown) => {}); + const contentPostMessage = mock((_body: unknown, _origin: string) => {}); + + const contentWindow = { + postMessage: contentPostMessage, + }; + + const iframe = { + style: {} as Record, + setAttribute: mock((_name: string, _value: string) => {}), + addEventListener: (name: string, fn: (event: unknown) => void) => { + iframeListeners.set(name, fn); + }, + removeEventListener: () => {}, + remove: mock(() => {}), + referrerPolicy: "", + credentialless: false, + allow: "", + src: "", + contentWindow, + }; + + const container = { + appendChild: mock((_child: unknown) => {}), + }; + + // Spy on both MessageChannel ports so dispose() teardown is observable. + const port1 = { postMessage: mock(() => {}), close: mock(() => {}) }; + const port2 = { postMessage: mock(() => {}), close: mock(() => {}) }; + globalThis.MessageChannel = class { + port1 = port1; + port2 = port2; + } as unknown as typeof MessageChannel; + + globalThis.document = { + createElement: (tag: string) => { + expect(tag).toBe("iframe"); + return iframe as unknown as HTMLIFrameElement; + }, + } as unknown as Document; + globalThis.window = { + location: { href: "http://localhost:5174/" }, + addEventListener: (name: string, fn: (event: unknown) => void) => { + windowListeners.set(name, fn); + }, + removeEventListener: windowRemove, + } as unknown as Window & typeof globalThis; + + return { + iframe, + container, + contentPostMessage, + contentWindow, + iframeListeners, + windowListeners, + windowRemove, + port1, + port2, + }; +} + +function teardownFakeDom() { + delete (globalThis as { document?: unknown }).document; + delete (globalThis as { window?: unknown }).window; + delete (globalThis as { MessageChannel?: unknown }).MessageChannel; +} + +describe("createIframeHost", () => { + let dom: ReturnType; + + beforeEach(() => { + dom = setupFakeDom(); + }); + + afterEach(() => { + teardownFakeDom(); + }); + + it("hands back a MessagePort and configures the iframe", () => { + const { iframe, container, iframeListeners, windowRemove, port1, port2 } = dom; + + let receivedPort: MessagePort | null = null; + const host = createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: container as unknown as HTMLElement, + allow: "camera; cross-origin-isolated", + onPort: (port) => { + receivedPort = port; + }, + }); + + expect(receivedPort).toBeTruthy(); + expect(typeof receivedPort!.postMessage).toBe("function"); + expect(container.appendChild.mock.calls.length).toBe(1); + expect(host.iframe).toBe(iframe as unknown as HTMLIFrameElement); + expect(iframe.credentialless).toBe(true); + expect(iframe.allow).toBe("camera; cross-origin-isolated"); + expect(iframe.src).toBe("http://localhost:5174/"); + // port transfer waits for explicit iframe readiness + expect(iframeListeners.has("load")).toBe(false); + + host.dispose(); + expect(iframe.remove.mock.calls.length).toBe(1); + // dispose removes the window message listener + expect(windowRemove.mock.calls.length).toBe(1); + expect(windowRemove.mock.calls[0][0]).toBe("message"); + // host + product ports closed on dispose + expect(port1.close.mock.calls.length).toBe(1); + expect(port2.close.mock.calls.length).toBe(1); + }); + + it("sends truapi-init on a same-origin product-ready message", () => { + const { contentPostMessage, windowListeners, contentWindow } = dom; + + createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + }); + + const onMessage = windowListeners.get("message"); + expect(onMessage).toBeTruthy(); + + // Wrong source is dropped. + onMessage!({ + source: { other: true }, + origin: "http://localhost:5174", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(0); + + // Wrong origin is dropped. + onMessage!({ + source: contentWindow, + origin: "http://evil.example", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(0); + + // Correct source + origin triggers the init handshake. + onMessage!({ + source: contentWindow, + origin: "http://localhost:5174", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(1); + const [body, origin] = contentPostMessage.mock.calls[0]; + expect(body).toEqual({ type: "truapi-init" }); + expect(origin).toBe("*"); + + // The handshake is idempotent across repeated ready events too. + onMessage!({ + source: contentWindow, + origin: "http://localhost:5174", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(1); + }); + + it("accepts product-ready from a credentialless opaque origin", () => { + const { contentPostMessage, windowListeners, contentWindow } = dom; + + createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + }); + + const onMessage = windowListeners.get("message"); + expect(onMessage).toBeTruthy(); + + onMessage!({ + source: contentWindow, + origin: "null", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(1); + const [, origin] = contentPostMessage.mock.calls[0]; + expect(origin).toBe("*"); + }); + + it("rejects a mismatched allowedOrigin", () => { + expect(() => + createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + allowedOrigin: "http://localhost:9999", + }), + ).toThrow(/origin policy mismatch/); + }); + + it("rejects non-http(s) iframe URLs", () => { + expect(() => + createIframeHost({ + iframeUrl: "file:///etc/passwd", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + }), + ).toThrow(/only allows http\(s\)/); + }); +}); diff --git a/js/packages/truapi-host/src/web/create-iframe-host.ts b/js/packages/truapi-host/src/web/create-iframe-host.ts new file mode 100644 index 00000000..b5e3ce36 --- /dev/null +++ b/js/packages/truapi-host/src/web/create-iframe-host.ts @@ -0,0 +1,147 @@ +/** + * Options for `createIframeHost`. + */ +export interface IframeHostOptions { + /** URL of the product iframe. */ + iframeUrl: string; + /** Container element the iframe is appended to. */ + container: HTMLElement; + /** + * Called with one end of the MessageChannel once the iframe has loaded. + * Hosts typically pipe this into a `WireProvider` (e.g. via + * `createMessagePortProvider` from `@parity/truapi`). + */ + onPort: (port: MessagePort) => void; + /** + * Optional explicit allow-list origin. Defaults to the origin of + * `iframeUrl`. Throws if it disagrees with the iframe URL's origin. + */ + allowedOrigin?: string; + /** Optional iframe Permissions Policy allow attribute. */ + allow?: string; + /** Override the default iframe sandbox attribute. */ + sandbox?: string; +} + +/** + * Handle returned by `createIframeHost`. + */ +export interface IframeHost { + iframe: HTMLIFrameElement; + dispose: () => void; +} + +const DEFAULT_IFRAME_SANDBOX = "allow-forms allow-same-origin allow-scripts"; +type CredentiallessIframe = HTMLIFrameElement & { credentialless?: boolean }; + +function resolveAllowedOrigin( + iframeUrl: string, + allowedOrigin?: string, +): string { + const targetUrl = new URL(iframeUrl, window.location.href); + if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") { + throw new Error( + `Iframe host only allows http(s) product URLs, received ${targetUrl.protocol}`, + ); + } + + if (!allowedOrigin) { + return targetUrl.origin; + } + + const normalizedOrigin = new URL(allowedOrigin).origin; + if (normalizedOrigin !== targetUrl.origin) { + throw new Error( + `Iframe host origin policy mismatch, expected ${normalizedOrigin}, got ${targetUrl.origin}`, + ); + } + + return normalizedOrigin; +} + +/** + * Embed a product iframe and transfer a `MessagePort` into it. The host + * keeps the other end and passes it to a `WireProvider` (typically via + * `createMessagePortProvider`). All product traffic flows over the + * MessageChannel. + */ +export function createIframeHost(options: IframeHostOptions): IframeHost { + const { + iframeUrl, + container, + onPort, + allowedOrigin, + allow, + sandbox = DEFAULT_IFRAME_SANDBOX, + } = options; + + const channel = new MessageChannel(); + const hostPort = channel.port1; + const productPort = channel.port2; + const targetOrigin = resolveAllowedOrigin(iframeUrl, allowedOrigin); + + // Hand the host-side port to the caller immediately so it can wire up + // a provider before the iframe finishes loading. Queued postMessage + // calls are delivered once the channel is started by the provider. + onPort(hostPort); + + const iframe = document.createElement("iframe"); + iframe.style.width = "100%"; + iframe.style.height = "100%"; + iframe.style.border = "none"; + // COEP hosts need credentialless product iframes when the product origin + // does not serve matching embedder headers. + const credentiallessIframe = iframe as CredentiallessIframe; + credentiallessIframe.credentialless = true; + if (allow !== undefined) { + iframe.allow = allow; + } + iframe.setAttribute("sandbox", sandbox); + iframe.referrerPolicy = "no-referrer"; + iframe.src = iframeUrl; + const initTargetOrigin = credentiallessIframe.credentialless + ? "*" + : targetOrigin; + + let initSent = false; + const sendInit = (): void => { + if (initSent) return; + const contentWindow = iframe.contentWindow; + if (!contentWindow) return; + initSent = true; + contentWindow.postMessage({ type: "truapi-init" }, initTargetOrigin, [ + productPort, + ]); + }; + + const onWindowMessage = (event: MessageEvent): void => { + if (event.source !== iframe.contentWindow) return; + if (event.origin !== targetOrigin && event.origin !== "null") return; + if (event.data?.type === "truapi-ready") { + sendInit(); + } + }; + window.addEventListener("message", onWindowMessage); + + container.appendChild(iframe); + + return { + iframe, + dispose() { + window.removeEventListener("message", onWindowMessage); + try { + hostPort.close(); + } catch { + // already closed + } + try { + productPort.close(); + } catch { + // already closed + } + iframe.remove(); + }, + }; +} + +export type { WireProvider } from "@parity/truapi"; 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 new file mode 100644 index 00000000..3116816d --- /dev/null +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -0,0 +1,896 @@ +import type { + ChainConnection, + ProductRuntimeConfig, + LogLevel, + PermissionAuthorizationRequest, + PermissionAuthorizationStatus, + RequiredHostCallbacks, + TrUApiProductProvider, +} from "../index.js"; +import { PermissionAuthorizationRequest as PermissionAuthorizationRequestCodec } from "../generated/host-callbacks.js"; +import { createWasmRawCallbacks } from "../generated/host-callbacks-adapter.js"; +import type { RawCallbacks } from "../generated/host-callbacks-adapter.js"; +import type { + CallbackName, + MainToWorker, + SubscriptionName, + WorkerToMain, +} from "../worker-protocol.js"; +import { bytesToHex } from "@parity/truapi/scale"; +import { startRawSubscription } from "../generated/worker-callbacks.js"; +import { errorMessage } from "../error.js"; + +export type WebWorkerHostConfig = Omit; + +export interface WorkerPairingHostRuntime { + createProvider(product: { + productId: string; + }): Promise; + disconnectSession(): Promise; + cancelPairing(): void; + notifySessionStoreChanged(): void; + getPermissionAuthorizationStatus( + productId: string, + request: PermissionAuthorizationRequest, + ): Promise; + getPermissionAuthorizationStatuses( + productId: string, + requests: PermissionAuthorizationRequest[], + ): Promise; + setPermissionAuthorizationStatus( + productId: string, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ): Promise; + setLogLevel(level: LogLevel): void; + dispose(): void; +} + +interface CoreState { + coreId: number; + productId: string; + listeners: Set<(message: Uint8Array) => void>; + closeListeners: Set<(error: Error) => void>; + closedError: Error | null; + disposed: boolean; +} + +interface RuntimeState { + worker: Worker; + rawCallbacks: RawCallbacks; + cores: Map; + pendingCores: Map< + number, + { + productId: string; + resolve: (provider: TrUApiProductProvider) => void; + reject: (error: Error) => void; + } + >; + subscriptionDisposers: Map void>; + chainConnections: Map; + pendingDisconnects: Map< + number, + { resolve: () => void; reject: (error: Error) => void } + >; + pendingPermissionAuthorizationStatuses: Map< + number, + { + resolve: (status: PermissionAuthorizationStatus) => void; + reject: (error: Error) => void; + } + >; + pendingPermissionAuthorizationStatusBatches: Map< + number, + { + resolve: (statuses: PermissionAuthorizationStatus[]) => void; + reject: (error: Error) => void; + } + >; + pendingSetPermissionAuthorizationStatuses: Map< + number, + { resolve: () => void; reject: (error: Error) => void } + >; + closedError: Error | null; + logLevel: LogLevel; + disposed: boolean; + nextCoreId: number; +} + +function debugLoggingEnabled(state: RuntimeState): boolean { + return state.logLevel === "debug" || state.logLevel === "trace"; +} + +let nextDisconnectRequestId = 0; +let nextPermissionAuthorizationRequestId = 0; + +function encodePermissionAuthorizationRequest( + request: PermissionAuthorizationRequest, +): Uint8Array { + return PermissionAuthorizationRequestCodec.enc(request); +} + +const DEV_LOG_LEVEL_KEY = "truapi:logLevel"; + +function readPersistedLogLevel(): LogLevel | null { + return globalThis.localStorage?.getItem(DEV_LOG_LEVEL_KEY) ?? null; +} + +function persistLogLevel(level: LogLevel): void { + globalThis.localStorage?.setItem(DEV_LOG_LEVEL_KEY, level); +} + +let devLogLevelOverride: LogLevel | null = readPersistedLogLevel(); +const devGlobalTargets = new Set<{ setLogLevel?: (level: LogLevel) => void }>(); +interface TrUApiDevConsole { + setLogLevel(level: LogLevel): void; + getLogLevel(): LogLevel | null; +} + +function handleCallbackRequest( + state: RuntimeState, + msg: { + requestId: number; + name: CallbackName; + args: readonly unknown[]; + }, +): void { + const fn = Object.hasOwn(state.rawCallbacks, msg.name) + ? ( + state.rawCallbacks as unknown as Record< + string, + (...args: readonly unknown[]) => unknown + > + )[msg.name] + : undefined; + if (!fn) { + state.worker.postMessage({ + kind: "callbackResponse", + requestId: msg.requestId, + ok: false, + error: `unknown callback: ${msg.name}`, + } satisfies MainToWorker); + return; + } + Promise.resolve() + .then(() => fn(...msg.args)) + .then( + (value) => { + state.worker.postMessage({ + kind: "callbackResponse", + requestId: msg.requestId, + ok: true, + value, + } satisfies MainToWorker); + }, + (err) => { + state.worker.postMessage({ + kind: "callbackResponse", + requestId: msg.requestId, + ok: false, + error: errorMessage(err), + } satisfies MainToWorker); + }, + ); +} + +function handleSubscriptionStart( + state: RuntimeState, + msg: { + subId: number; + name: SubscriptionName; + payload: Uint8Array | null; + }, +): void { + const sendItem = (value?: unknown): void => { + if (state.disposed) return; + state.worker.postMessage({ + kind: "subscriptionItem", + subId: msg.subId, + value, + } satisfies MainToWorker); + }; + let dispose: (() => void) | void = undefined; + try { + dispose = startRawSubscription( + state.rawCallbacks, + msg.name, + msg.payload, + sendItem, + ); + } catch (err) { + console.error(`[truapi worker] ${msg.name} threw on start:`, err); + return; + } + if (typeof dispose === "function") { + state.subscriptionDisposers.set(msg.subId, dispose); + } +} + +function handleSubscriptionStop( + state: RuntimeState, + msg: { subId: number }, +): void { + const dispose = state.subscriptionDisposers.get(msg.subId); + if (!dispose) return; + state.subscriptionDisposers.delete(msg.subId); + try { + dispose(); + } catch (err) { + console.warn("[truapi worker] subscription dispose threw:", err); + } +} + +async function handleChainConnectStart( + state: RuntimeState, + msg: { connId: number; genesisHash: string }, +): Promise { + const chainConnect = state.rawCallbacks.chainConnect; + const onResponse = (json: string): void => { + if (state.disposed) return; + state.worker.postMessage({ + kind: "chainResponse", + connId: msg.connId, + json, + } satisfies MainToWorker); + }; + try { + const conn = await chainConnect(msg.genesisHash, onResponse); + if (!conn) { + state.worker.postMessage({ + kind: "chainConnectAck", + connId: msg.connId, + ok: false, + error: `chainConnect returned null for genesisHash ${msg.genesisHash}`, + } satisfies MainToWorker); + return; + } + state.chainConnections.set(msg.connId, conn); + state.worker.postMessage({ + kind: "chainConnectAck", + connId: msg.connId, + ok: true, + } satisfies MainToWorker); + } catch (err) { + state.worker.postMessage({ + kind: "chainConnectAck", + connId: msg.connId, + ok: false, + error: errorMessage(err), + } satisfies MainToWorker); + } +} + +function handleChainSend( + state: RuntimeState, + msg: { connId: number; request: string }, +): void { + const conn = state.chainConnections.get(msg.connId); + if (!conn) return; + try { + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] chainSend", msg.connId, msg.request); + } + conn.send(msg.request); + } catch (err) { + console.warn("[truapi worker] chain send threw:", err); + } +} + +function handleChainClose(state: RuntimeState, msg: { connId: number }): void { + const conn = state.chainConnections.get(msg.connId); + if (!conn) return; + state.chainConnections.delete(msg.connId); + try { + conn.close(); + } catch (err) { + console.warn("[truapi worker] chain close threw:", err); + } +} + +interface PendingEntry { + resolve: (value: T) => void; + reject: (error: Error) => void; +} + +function settlePending( + map: Map>, + requestId: number, + result: { ok: true; value: T } | { ok: false; error: string }, +): void { + const pending = map.get(requestId); + if (!pending) return; + map.delete(requestId); + if (result.ok) pending.resolve(result.value); + else pending.reject(new Error(result.error)); +} + +function rejectAll(map: Map>, error: Error): void { + for (const pending of map.values()) { + pending.reject(error); + } + map.clear(); +} + +function handleDisconnectResponse( + state: RuntimeState, + msg: + | { requestId: number; ok: true } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingDisconnects, + msg.requestId, + msg.ok ? { ok: true, value: undefined } : { ok: false, error: msg.error }, + ); +} + +function handlePermissionAuthorizationStatusResponse( + state: RuntimeState, + msg: + | { + requestId: number; + ok: true; + status: PermissionAuthorizationStatus; + } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingPermissionAuthorizationStatuses, + msg.requestId, + msg.ok ? { ok: true, value: msg.status } : { ok: false, error: msg.error }, + ); +} + +function handlePermissionAuthorizationStatusesResponse( + state: RuntimeState, + msg: + | { + requestId: number; + ok: true; + statuses: PermissionAuthorizationStatus[]; + } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingPermissionAuthorizationStatusBatches, + msg.requestId, + msg.ok + ? { ok: true, value: msg.statuses } + : { ok: false, error: msg.error }, + ); +} + +function handleSetPermissionAuthorizationStatusResponse( + state: RuntimeState, + msg: + | { requestId: number; ok: true } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingSetPermissionAuthorizationStatuses, + msg.requestId, + msg.ok ? { ok: true, value: undefined } : { ok: false, error: msg.error }, + ); +} + +function rejectPendingRuntimeRequests(state: RuntimeState, error: Error): void { + rejectAll(state.pendingDisconnects, error); + rejectAll(state.pendingPermissionAuthorizationStatuses, error); + rejectAll(state.pendingPermissionAuthorizationStatusBatches, error); + rejectAll(state.pendingSetPermissionAuthorizationStatuses, error); + for (const pending of state.pendingCores.values()) { + pending.reject(error); + } + state.pendingCores.clear(); +} + +function sendWorkerRequest( + state: RuntimeState, + pending: Map>, + nextId: () => number, + disposedFallback: T, + buildMessage: (requestId: number) => MainToWorker, +): Promise { + if (state.disposed) return Promise.resolve(disposedFallback); + return new Promise((resolve, reject) => { + const requestId = nextId(); + pending.set(requestId, { resolve, reject }); + try { + state.worker.postMessage(buildMessage(requestId)); + } catch (err) { + pending.delete(requestId); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); +} + +function closeCoreState(core: CoreState, error: Error): void { + if (core.disposed) return; + core.disposed = true; + core.closedError = error; + for (const listener of [...core.closeListeners]) listener(error); + core.listeners.clear(); + core.closeListeners.clear(); +} + +function teardown(state: RuntimeState, error: Error, fault: boolean): void { + if (state.disposed) return; + state.disposed = true; + state.closedError = error; + rejectPendingRuntimeRequests(state, error); + for (const core of state.cores.values()) { + closeCoreState(core, error); + } + state.cores.clear(); + for (const fn of state.subscriptionDisposers.values()) { + try { + fn(); + } catch { + // ignore during teardown + } + } + state.subscriptionDisposers.clear(); + for (const conn of state.chainConnections.values()) { + try { + conn.close(); + } catch { + // ignore during teardown + } + } + state.chainConnections.clear(); + if (fault) { + state.worker.terminate(); + } else { + try { + state.worker.postMessage({ kind: "dispose" } satisfies MainToWorker); + } catch { + // ignore if worker already gone + } + setTimeout(() => state.worker.terminate(), 0); + } +} + +export interface CreateWebWorkerPairingHostRuntimeOptions { + logLevel?: LogLevel; + hostConfig: WebWorkerHostConfig; + initTimeoutMs?: number; +} + +export type WebWorkerHostCallbacks = RequiredHostCallbacks; + +export function createWebWorkerPairingHostRuntime( + worker: Worker, + host: WebWorkerHostCallbacks, + options: CreateWebWorkerPairingHostRuntimeOptions, +): Promise { + const callbacks = createWasmRawCallbacks(host); + + return new Promise((resolve, reject) => { + const state: RuntimeState = { + worker, + rawCallbacks: callbacks, + cores: new Map(), + pendingCores: new Map(), + subscriptionDisposers: new Map(), + chainConnections: new Map(), + pendingDisconnects: new Map(), + pendingPermissionAuthorizationStatuses: new Map(), + pendingPermissionAuthorizationStatusBatches: new Map(), + pendingSetPermissionAuthorizationStatuses: new Map(), + closedError: null, + logLevel: devLogLevelOverride ?? options.logLevel ?? "off", + disposed: false, + nextCoreId: 0, + }; + + let runtime: WorkerPairingHostRuntime | null = null; + + const notifyFault = (error: Error): void => { + teardown(state, error, true); + }; + + const onMessage = (ev: MessageEvent): void => { + const msg = ev.data; + switch (msg.kind) { + case "loaded": + case "ready": + break; + case "coreReady": + handleCoreReady(state, msg.coreId, runtime); + break; + case "coreError": + handleCoreError(state, msg.coreId, msg.error); + break; + case "fatalError": + console.error("[truapi worker]", msg.error); + notifyFault(new Error(`worker fatal error: ${msg.error}`)); + break; + case "frameError": + handleFrameError(state, msg.coreId, msg.error); + break; + case "disposeError": + console.warn("[truapi worker] dispose:", msg.error); + break; + case "frame": { + const core = state.cores.get(msg.coreId); + if (!core || core.disposed) break; + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] frame <-", bytesToHex(msg.bytes)); + } + for (const listener of [...core.listeners]) listener(msg.bytes); + break; + } + case "disconnectSessionResponse": + handleDisconnectResponse(state, msg); + break; + case "permissionAuthorizationStatusResponse": + handlePermissionAuthorizationStatusResponse(state, msg); + break; + case "permissionAuthorizationStatusesResponse": + handlePermissionAuthorizationStatusesResponse(state, msg); + break; + case "setPermissionAuthorizationStatusResponse": + handleSetPermissionAuthorizationStatusResponse(state, msg); + break; + case "callbackRequest": + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] callbackRequest", msg.name); + } + handleCallbackRequest(state, msg); + break; + case "subscriptionStart": + handleSubscriptionStart(state, msg); + break; + case "subscriptionStop": + handleSubscriptionStop(state, msg); + break; + case "chainConnectStart": + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] chainConnectStart", msg.connId); + } + void handleChainConnectStart(state, msg); + break; + case "chainSend": + handleChainSend(state, msg); + break; + case "chainClose": + handleChainClose(state, msg); + break; + default: { + const { kind } = msg as { kind?: unknown }; + console.warn( + `[truapi worker] unknown worker message kind: ${String(kind)}`, + ); + } + } + }; + + const onError = (e: ErrorEvent): void => { + cleanupInit(); + worker.terminate(); + reject(new Error(`worker init failed: ${e.message}`)); + }; + + const onInitMessageError = (): void => { + cleanupInit(); + worker.terminate(); + reject(new Error("worker message could not be deserialized during init")); + }; + + const onRuntimeError = (e: ErrorEvent): void => { + console.error("[truapi worker]", e.message); + notifyFault(new Error(`worker error: ${e.message}`)); + }; + + const onMessageError = (): void => { + notifyFault(new Error("worker message could not be deserialized")); + }; + + const onInitMessage = (ev: MessageEvent): void => { + const msg = ev.data; + if (msg.kind === "loaded") { + worker.postMessage({ + kind: "init", + logLevel: devLogLevelOverride ?? options.logLevel ?? "off", + hostConfig: options.hostConfig, + } satisfies MainToWorker); + } else if (msg.kind === "ready") { + cleanupInit(); + worker.addEventListener("message", onMessage); + worker.addEventListener("error", onRuntimeError); + worker.addEventListener("messageerror", onMessageError); + runtime = buildRuntime(state); + exposeDevGlobal(runtime); + resolve(runtime); + } else if (msg.kind === "fatalError") { + cleanupInit(); + worker.terminate(); + reject(new Error(`worker init reported error: ${msg.error}`)); + } + }; + + const cleanupInit = (): void => { + clearTimeout(initTimeout); + worker.removeEventListener("error", onError); + worker.removeEventListener("messageerror", onInitMessageError); + worker.removeEventListener("message", onInitMessage); + }; + + const timeoutMs = options.initTimeoutMs ?? 30_000; + const initTimeout = setTimeout(() => { + cleanupInit(); + worker.terminate(); + reject(new Error(`worker init timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + worker.addEventListener("error", onError); + worker.addEventListener("messageerror", onInitMessageError); + worker.addEventListener("message", onInitMessage); + }); +} + +function handleCoreReady( + state: RuntimeState, + coreId: number, + runtime: WorkerPairingHostRuntime | null, +): void { + const pending = state.pendingCores.get(coreId); + if (!pending || !runtime) return; + state.pendingCores.delete(coreId); + const core: CoreState = { + coreId, + productId: pending.productId, + listeners: new Set(), + closeListeners: new Set(), + closedError: null, + disposed: false, + }; + state.cores.set(coreId, core); + pending.resolve(buildProvider(state, core, runtime)); +} + +function handleCoreError( + state: RuntimeState, + coreId: number, + error: string, +): void { + const pending = state.pendingCores.get(coreId); + if (!pending) return; + state.pendingCores.delete(coreId); + pending.reject(new Error(error)); +} + +function handleFrameError( + state: RuntimeState, + coreId: number, + error: string, +): void { + console.error("[truapi worker]", error); + const core = state.cores.get(coreId); + if (!core) return; + closeCoreState(core, new Error(`worker frame error: ${error}`)); + state.cores.delete(coreId); + try { + state.worker.postMessage({ + kind: "disposeCore", + coreId, + } satisfies MainToWorker); + } catch { + // ignore if worker is already gone + } +} + +function buildRuntime(state: RuntimeState): WorkerPairingHostRuntime { + const runtime: WorkerPairingHostRuntime = { + createProvider(product): Promise { + if (state.disposed) { + return Promise.reject( + state.closedError ?? new Error("runtime disposed"), + ); + } + return new Promise((resolve, reject) => { + const coreId = ++state.nextCoreId; + state.pendingCores.set(coreId, { + productId: product.productId, + resolve, + reject, + }); + try { + state.worker.postMessage({ + kind: "createCore", + coreId, + product, + } satisfies MainToWorker); + } catch (err) { + state.pendingCores.delete(coreId); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + }, + disconnectSession(): Promise { + return sendWorkerRequest( + state, + state.pendingDisconnects, + () => ++nextDisconnectRequestId, + undefined, + (requestId) => ({ kind: "disconnectSession", requestId }), + ); + }, + cancelPairing(): void { + if (state.disposed) return; + state.worker.postMessage({ + kind: "cancelPairing", + } satisfies MainToWorker); + }, + notifySessionStoreChanged(): void { + if (state.disposed) return; + state.worker.postMessage({ + kind: "notifySessionStoreChanged", + } satisfies MainToWorker); + }, + getPermissionAuthorizationStatus(productId, request) { + return sendWorkerRequest( + state, + state.pendingPermissionAuthorizationStatuses, + () => ++nextPermissionAuthorizationRequestId, + "NotDetermined", + (requestId) => ({ + kind: "getPermissionAuthorizationStatus", + productId, + requestId, + request: encodePermissionAuthorizationRequest(request), + }), + ); + }, + getPermissionAuthorizationStatuses(productId, requests) { + return sendWorkerRequest( + state, + state.pendingPermissionAuthorizationStatusBatches, + () => ++nextPermissionAuthorizationRequestId, + requests.map(() => "NotDetermined"), + (requestId) => ({ + kind: "getPermissionAuthorizationStatuses", + productId, + requestId, + requests: requests.map(encodePermissionAuthorizationRequest), + }), + ); + }, + setPermissionAuthorizationStatus(productId, request, status) { + return sendWorkerRequest( + state, + state.pendingSetPermissionAuthorizationStatuses, + () => ++nextPermissionAuthorizationRequestId, + undefined, + (requestId) => ({ + kind: "setPermissionAuthorizationStatus", + productId, + requestId, + request: encodePermissionAuthorizationRequest(request), + status, + }), + ); + }, + setLogLevel(level): void { + if (state.disposed) return; + state.logLevel = level; + state.worker.postMessage({ + kind: "setLogLevel", + level, + } satisfies MainToWorker); + }, + dispose(): void { + devGlobalTargets.delete(runtime); + teardown(state, new Error("runtime disposed"), false); + }, + }; + return runtime; +} + +function buildProvider( + state: RuntimeState, + core: CoreState, + runtime: WorkerPairingHostRuntime, +): TrUApiProductProvider { + const provider: TrUApiProductProvider = { + postMessage(bytes: Uint8Array): void { + if (state.disposed || core.disposed) return; + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] frame ->", bytesToHex(bytes)); + } + state.worker.postMessage({ + kind: "frame", + coreId: core.coreId, + bytes, + } satisfies MainToWorker); + }, + subscribe(callback) { + core.listeners.add(callback); + return () => { + core.listeners.delete(callback); + }; + }, + subscribeClose(callback) { + const closed = core.closedError ?? state.closedError; + if (closed) { + callback(closed); + return () => {}; + } + core.closeListeners.add(callback); + return () => { + core.closeListeners.delete(callback); + }; + }, + disconnectSession(): Promise { + if (core.disposed) return Promise.resolve(); + return runtime.disconnectSession(); + }, + getPermissionAuthorizationStatus(request) { + if (core.disposed) return Promise.resolve("NotDetermined"); + return runtime.getPermissionAuthorizationStatus(core.productId, request); + }, + getPermissionAuthorizationStatuses(requests) { + if (core.disposed) { + return Promise.resolve(requests.map(() => "NotDetermined")); + } + return runtime.getPermissionAuthorizationStatuses( + core.productId, + requests, + ); + }, + setPermissionAuthorizationStatus(request, status) { + if (core.disposed) return Promise.resolve(); + return runtime.setPermissionAuthorizationStatus( + core.productId, + request, + status, + ); + }, + setLogLevel(level): void { + if (core.disposed) return; + runtime.setLogLevel(level); + }, + dispose(): void { + if (core.disposed) return; + closeCoreState(core, new Error("provider disposed")); + state.cores.delete(core.coreId); + state.worker.postMessage({ + kind: "disposeCore", + coreId: core.coreId, + } satisfies MainToWorker); + }, + }; + return provider; +} + +function exposeDevGlobal(target: { + setLogLevel?: (level: LogLevel) => void; +}): void { + devGlobalTargets.add(target); + if (devLogLevelOverride !== null) { + target.setLogLevel?.(devLogLevelOverride); + } + publishDevGlobal(); +} + +function publishDevGlobal(): void { + const target = globalThis as { + __truapi?: TrUApiDevConsole; + }; + target.__truapi = { + setLogLevel(level: LogLevel): void { + devLogLevelOverride = level; + persistLogLevel(level); + for (const provider of [...devGlobalTargets]) { + provider.setLogLevel?.(level); + } + console.info(`[truapi worker] logLevel=${level}`); + }, + getLogLevel(): LogLevel | null { + return devLogLevelOverride; + }, + }; +} + +publishDevGlobal(); diff --git a/js/packages/truapi-host/src/web/index.ts b/js/packages/truapi-host/src/web/index.ts new file mode 100644 index 00000000..acba3a71 --- /dev/null +++ b/js/packages/truapi-host/src/web/index.ts @@ -0,0 +1,9 @@ +export type { IframeHost, IframeHostOptions } from "./create-iframe-host.js"; +export { createIframeHost } from "./create-iframe-host.js"; +export type { + CreateWebWorkerPairingHostRuntimeOptions, + WebWorkerHostConfig, + WebWorkerHostCallbacks, + WorkerPairingHostRuntime, +} from "./create-worker-host-runtime.js"; +export { createWebWorkerPairingHostRuntime } from "./create-worker-host-runtime.js"; diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts new file mode 100644 index 00000000..7a0cc086 --- /dev/null +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -0,0 +1,861 @@ +import { describe, expect, it } from "bun:test"; +import { ok } from "neverthrow"; + +import { + HostPushNotificationRequest, + HostPushNotificationResponse, +} from "@parity/truapi"; +import type { GenericError, Result, ThemeVariant } from "@parity/truapi"; + +import { createWasmRawCallbacks } from "../generated/host-callbacks-adapter.js"; +import { AuthState, CoreStorageKey } from "../generated/host-callbacks.js"; +import type { + AuthState as AuthStateValue, + PreimageHost, +} from "../generated/host-callbacks.js"; +import type { + ProductRuntimeConfig, + TrUApiProductProvider, +} from "../runtime.js"; +import { makeHostCallbacks, settle } from "../test-support.js"; +import { createWebWorkerPairingHostRuntime } from "./index.js"; +import type { CreateWebWorkerPairingHostRuntimeOptions } from "./index.js"; + +type WorkerMessage = Record; + +/** Minimal `Worker` stand-in that records posted messages and lets a test + * drive the `message`/`error`/`messageerror` events by hand. */ +class FakeWorker { + listeners = new Map void>>(); + messages: WorkerMessage[] = []; + terminated = false; + + addEventListener(name: string, fn: (event: unknown) => void) { + const listeners = this.listeners.get(name) ?? new Set(); + listeners.add(fn); + this.listeners.set(name, listeners); + } + + removeEventListener(name: string, fn: (event: unknown) => void) { + this.listeners.get(name)?.delete(fn); + } + + postMessage(message: WorkerMessage) { + this.messages.push(message); + } + + terminate() { + this.terminated = true; + } + + emit(message: WorkerMessage) { + for (const listener of this.listeners.get("message") ?? []) { + listener({ data: message }); + } + } + + emitError(message: string) { + for (const listener of this.listeners.get("error") ?? []) { + listener({ message }); + } + } + + emitMessageError() { + for (const listener of this.listeners.get("messageerror") ?? []) { + listener({ data: null }); + } + } +} + +/** Coerce the `FakeWorker` to the `Worker` shape the provider expects. */ +function asWorker(worker: FakeWorker): Worker { + return worker as unknown as Worker; +} + +function runtimeConfig( + overrides: Partial = {}, +): ProductRuntimeConfig { + return { + productId: "dotli.dot", + host: { + name: "Polkadot Web", + icon: "https://dot.li/dotli.png", + version: "0.5.0", + }, + platform: { + type: "node", + version: process.versions.node, + }, + people: { + genesisHash: + "0xa22a2424d2cbf561eaecf7da8b1b548fa9d1939f60265e942b1049616a012f71", + }, + bulletin: { + genesisHash: + "0x2e7ffab63d8e86f6828a42445c7e3036f6bb47dd437e52a65457015f0605d8be", + }, + pairing: { + deeplinkScheme: "polkadotapp", + }, + ...overrides, + }; +} + +function hostConfigFromRuntimeConfig( + config: ProductRuntimeConfig, +): CreateWebWorkerPairingHostRuntimeOptions["hostConfig"] { + const { productId: _productId, ...hostConfig } = config; + return hostConfig; +} + +function lastMessageOfKind(worker: FakeWorker, kind: string): WorkerMessage { + const message = [...worker.messages].reverse().find((m) => m.kind === kind); + expect(message).toBeDefined(); + return message!; +} + +async function finishProviderReady( + worker: FakeWorker, + providerPromise: Promise, +) { + await settle(); + const createCore = lastMessageOfKind(worker, "createCore"); + worker.emit({ kind: "coreReady", coreId: createCore.coreId }); + return providerPromise; +} + +type ReadyOptions = Partial< + Omit +> & { + createWebWorkerPairingHostRuntime?: typeof createWebWorkerPairingHostRuntime; + runtimeConfig?: ProductRuntimeConfig; +}; + +async function createProviderFromRuntime( + worker: Worker, + host: ReturnType, + options: ReadyOptions, +): Promise { + const { + createWebWorkerPairingHostRuntime: + createRuntime = createWebWorkerPairingHostRuntime, + runtimeConfig: cfg = runtimeConfig(), + ...runtimeOptions + } = options; + const runtime = await createRuntime(worker, host, { + ...runtimeOptions, + hostConfig: hostConfigFromRuntimeConfig(cfg), + }); + const provider = await runtime.createProvider({ productId: cfg.productId }); + return { + ...provider, + dispose(): void { + provider.dispose(); + runtime.dispose(); + }, + }; +} + +async function readyProvider(worker: FakeWorker, options: ReadyOptions = {}) { + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks(), + options, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + return finishProviderReady(worker, providerPromise); +} + +/** Typed view of the dev console the worker runtime publishes on `globalThis`. */ +type TruapiDevConsole = { + setLogLevel(level: string): void; + getLogLevel(): string | null; +}; +const devGlobal = globalThis as typeof globalThis & { + __truapi?: TruapiDevConsole; +}; + +describe("createWebWorkerPairingHostRuntime", () => { + it("initializes the worker without a callback manifest", async () => { + const worker = new FakeWorker(); + const config = runtimeConfig(); + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks(), + { + logLevel: "debug", + runtimeConfig: config, + }, + ); + + worker.emit({ kind: "loaded" }); + expect(worker.messages.length).toBe(1); + expect(worker.messages[0]).toEqual({ + kind: "init", + logLevel: "debug", + hostConfig: hostConfigFromRuntimeConfig(config), + }); + + worker.emit({ kind: "ready" }); + await settle(); + const createCore = lastMessageOfKind(worker, "createCore"); + expect(createCore).toEqual({ + kind: "createCore", + coreId: 1, + product: { productId: "dotli.dot" }, + }); + worker.emit({ kind: "coreReady", coreId: 1 }); + const provider = await providerPromise; + expect(typeof provider.disconnectSession).toBe("function"); + + provider.dispose(); + }); + + it("creates multiple product cores on one worker runtime", async () => { + const worker = new FakeWorker(); + const config = runtimeConfig(); + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks(), + { + hostConfig: hostConfigFromRuntimeConfig(config), + }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + + const firstPromise = runtime.createProvider({ productId: "first.dot" }); + const secondPromise = runtime.createProvider({ productId: "second.dot" }); + + expect(worker.messages.at(-2)).toEqual({ + kind: "createCore", + coreId: 1, + product: { productId: "first.dot" }, + }); + expect(worker.messages.at(-1)).toEqual({ + kind: "createCore", + coreId: 2, + product: { productId: "second.dot" }, + }); + + worker.emit({ kind: "coreReady", coreId: 1 }); + worker.emit({ kind: "coreReady", coreId: 2 }); + const first = await firstPromise; + const second = await secondPromise; + + const firstFrames: Uint8Array[] = []; + const secondFrames: Uint8Array[] = []; + first.subscribe((frame) => firstFrames.push(frame)); + second.subscribe((frame) => secondFrames.push(frame)); + + worker.emit({ kind: "frame", coreId: 2, bytes: new Uint8Array([2]) }); + worker.emit({ kind: "frame", coreId: 1, bytes: new Uint8Array([1]) }); + expect(firstFrames).toEqual([new Uint8Array([1])]); + expect(secondFrames).toEqual([new Uint8Array([2])]); + + first.postMessage(new Uint8Array([9])); + expect(worker.messages.at(-1)).toEqual({ + kind: "frame", + coreId: 1, + bytes: new Uint8Array([9]), + }); + + first.dispose(); + expect(worker.messages.at(-1)).toEqual({ kind: "disposeCore", coreId: 1 }); + + worker.emit({ kind: "frame", coreId: 2, bytes: new Uint8Array([3]) }); + expect(firstFrames).toEqual([new Uint8Array([1])]); + expect(secondFrames).toEqual([new Uint8Array([2]), new Uint8Array([3])]); + + runtime.dispose(); + expect(worker.messages.at(-1)).toEqual({ kind: "dispose" }); + }); + + it("dev global setLogLevel updates every live worker provider", async () => { + const previous = devGlobal.__truapi; + delete devGlobal.__truapi; + const firstWorker = new FakeWorker(); + const secondWorker = new FakeWorker(); + const first = await readyProvider(firstWorker); + const second = await readyProvider(secondWorker); + + devGlobal.__truapi!.setLogLevel("debug"); + + expect(firstWorker.messages.at(-1)).toEqual({ + kind: "setLogLevel", + level: "debug", + }); + expect(secondWorker.messages.at(-1)).toEqual({ + kind: "setLogLevel", + level: "debug", + }); + expect(devGlobal.__truapi!.getLogLevel()).toBe("debug"); + + devGlobal.__truapi!.setLogLevel("off"); + first.dispose(); + second.dispose(); + if (previous === undefined) { + delete devGlobal.__truapi; + } else { + devGlobal.__truapi = previous; + } + }); + + it("dev global setLogLevel applies to providers created later", async () => { + const previous = devGlobal.__truapi; + delete devGlobal.__truapi; + const moduleUrl = `./create-worker-host-runtime.js?dev-global-${Date.now()}`; + const { + createWebWorkerPairingHostRuntime: freshCreateWebWorkerPairingHostRuntime, + } = (await import( + moduleUrl + )) as typeof import("./create-worker-host-runtime.js"); + + expect(typeof devGlobal.__truapi!.setLogLevel).toBe("function"); + devGlobal.__truapi!.setLogLevel("trace"); + + const firstWorker = new FakeWorker(); + const first = await readyProvider(firstWorker, { + createWebWorkerPairingHostRuntime: freshCreateWebWorkerPairingHostRuntime, + }); + first.dispose(); + + const secondWorker = new FakeWorker(); + const second = await readyProvider(secondWorker, { + createWebWorkerPairingHostRuntime: freshCreateWebWorkerPairingHostRuntime, + }); + + expect(secondWorker.messages[0].kind).toBe("init"); + expect(secondWorker.messages[0].logLevel).toBe("trace"); + expect( + secondWorker.messages.some((message) => { + return message.kind === "setLogLevel" && message.level === "trace"; + }), + ).toBe(true); + + second.dispose(); + devGlobal.__truapi!.setLogLevel("off"); + if (previous === undefined) { + delete devGlobal.__truapi; + } else { + devGlobal.__truapi = previous; + } + }); + + it("dev global setLogLevel persists the level to localStorage", async () => { + const previousGlobal = devGlobal.__truapi; + const previousStorage = globalThis.localStorage; + delete devGlobal.__truapi; + const store = new Map(); + globalThis.localStorage = { + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + setItem: (key: string, value: string) => store.set(key, String(value)), + } as unknown as Storage; + + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + + devGlobal.__truapi!.setLogLevel("debug"); + expect(store.get("truapi:logLevel")).toBe("debug"); + + devGlobal.__truapi!.setLogLevel("off"); + expect(store.get("truapi:logLevel")).toBe("off"); + + provider.dispose(); + globalThis.localStorage = previousStorage; + if (previousGlobal === undefined) { + delete devGlobal.__truapi; + } else { + devGlobal.__truapi = previousGlobal; + } + }); + + it("resolves disconnect responses", async () => { + const worker = new FakeWorker(); + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks(), + { + runtimeConfig: runtimeConfig(), + }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + const disconnect = provider.disconnectSession(); + const msg = worker.messages.at(-1)!; + expect(msg.kind).toBe("disconnectSession"); + expect(typeof msg.requestId).toBe("number"); + + worker.emit({ + kind: "disconnectSessionResponse", + requestId: msg.requestId, + ok: true, + }); + await disconnect; + + provider.dispose(); + }); + + it("dispatches callback requests to host hooks", async () => { + const worker = new FakeWorker(); + let clears = 0; + const authSessionKey = CoreStorageKey.enc({ tag: "AuthSession" }); + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + coreStorage: { + clearCoreStorage: async (key) => { + expect(key).toEqual({ tag: "AuthSession", value: undefined }); + clears += 1; + }, + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "callbackRequest", + requestId: 7, + name: "clearCoreStorage", + args: [authSessionKey], + }); + await settle(); + + expect(clears).toBe(1); + expect(worker.messages.at(-1)).toEqual({ + kind: "callbackResponse", + requestId: 7, + ok: true, + value: undefined, + }); + + provider.dispose(); + }); + + it("reports unknown callback requests", async () => { + const worker = new FakeWorker(); + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks(), + { + runtimeConfig: runtimeConfig(), + }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "callbackRequest", + requestId: 11, + name: "someFutureCallback", + args: [new Uint8Array([1, 2, 3])], + }); + await settle(); + + expect(worker.messages.at(-1)).toEqual({ + kind: "callbackResponse", + requestId: 11, + ok: false, + error: "unknown callback: someFutureCallback", + }); + + provider.dispose(); + }); + + it("forwards authStateChanged callback requests", async () => { + const worker = new FakeWorker(); + const states: AuthStateValue[] = []; + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + auth: { + authStateChanged: (state) => { + states.push(state); + }, + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + const publicKey = new Uint8Array(32); + publicKey.set([1, 2]); + + worker.emit({ + kind: "callbackRequest", + requestId: 3, + name: "authStateChanged", + args: [ + AuthState.enc({ + tag: "Connected", + value: { + publicKey, + liteUsername: "alice", + }, + }), + ], + }); + await settle(); + + expect(states).toEqual([ + { + tag: "Connected", + value: { + publicKey, + liteUsername: "alice", + }, + }, + ]); + expect(worker.messages.at(-1)).toEqual({ + kind: "callbackResponse", + requestId: 3, + ok: true, + value: undefined, + }); + + provider.dispose(); + }); + + it("posts cancelPairing to the worker", async () => { + const worker = new FakeWorker(); + const config = runtimeConfig(); + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks(), + { + hostConfig: hostConfigFromRuntimeConfig(config), + }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + + runtime.cancelPairing(); + + expect(worker.messages.at(-1)).toEqual({ kind: "cancelPairing" }); + runtime.dispose(); + }); + + it("posts notifySessionStoreChanged to the worker", async () => { + const worker = new FakeWorker(); + const config = runtimeConfig(); + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks(), + { + hostConfig: hostConfigFromRuntimeConfig(config), + }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + + runtime.notifySessionStoreChanged(); + + expect(worker.messages.at(-1)).toEqual({ + kind: "notifySessionStoreChanged", + }); + runtime.dispose(); + }); + + it("worker fault terminates the worker and runs the full teardown", async () => { + const worker = new FakeWorker(); + let subscriptionDisposes = 0; + let chainResponseStops = 0; + let chainCloses = 0; + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + // Manual async iterables whose `return()` records disposal; the + // provider disposes subscriptions and closes chain connections + // on a worker fault. + theme: { + subscribeTheme: () => + ({ + [Symbol.asyncIterator]() { + return this; + }, + next: () => new Promise(() => {}), + return: async () => { + subscriptionDisposes += 1; + return { done: true, value: undefined }; + }, + }) as unknown as AsyncIterable>, + }, + chain: { + connect: async () => ({ + send() {}, + responses: () => + ({ + [Symbol.asyncIterator]() { + return this; + }, + next: () => new Promise(() => {}), + return: async () => { + chainResponseStops += 1; + return { done: true, value: undefined }; + }, + }) as unknown as AsyncIterable, + close() { + chainCloses += 1; + }, + }), + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "subscriptionStart", + subId: 1, + name: "subscribeTheme", + payload: null, + }); + worker.emit({ + kind: "chainConnectStart", + connId: 1, + genesisHash: "0xab", + }); + await settle(); + await settle(); + + const closes: Error[] = []; + provider.subscribeClose!((error) => closes.push(error)); + + worker.emitError("boom"); + await settle(); + await settle(); + + expect(worker.terminated).toBe(true); + expect(subscriptionDisposes).toBe(1); + expect(chainResponseStops).toBe(1); + expect(chainCloses).toBe(1); + expect(closes.length).toBe(1); + expect(closes[0].message).toMatch(/boom/); + + // The fault teardown is terminal; a second fault is a no-op. + worker.emitError("again"); + expect(closes.length).toBe(1); + + let lateClose: Error | null = null; + provider.subscribeClose!((error) => { + lateClose = error; + }); + expect(lateClose).toBeInstanceOf(Error); + expect(lateClose!.message).toMatch(/boom/); + }); + + it("worker fatalError during init rejects provider creation", async () => { + const worker = new FakeWorker(); + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks(), + { + runtimeConfig: runtimeConfig(), + }, + ); + + worker.emit({ kind: "fatalError", error: "bad wasm" }); + + await expect(providerPromise).rejects.toThrow( + /worker init reported error: bad wasm/, + ); + expect(worker.terminated).toBe(true); + }); + + it("worker frameError after init closes the provider", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + const closes: Error[] = []; + provider.subscribeClose!((error) => closes.push(error)); + + worker.emit({ kind: "frameError", coreId: 1, error: "bad frame" }); + + expect(worker.terminated).toBe(false); + expect(worker.messages.at(-1)).toEqual({ kind: "disposeCore", coreId: 1 }); + expect(closes.length).toBe(1); + expect(closes[0].message).toMatch(/worker frame error: bad frame/); + + let lateClose: Error | null = null; + provider.subscribeClose!((error) => { + lateClose = error; + }); + expect(lateClose).toBeInstanceOf(Error); + provider.dispose(); + }); + + it("routes payload-carrying subscriptions by name", async () => { + const worker = new FakeWorker(); + const keys: Uint8Array[] = []; + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + preimage: { + lookupPreimage: async function* (key) { + keys.push(key); + yield ok(new Uint8Array([1])); + }, + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "subscriptionStart", + subId: 4, + name: "lookupPreimage", + payload: new Uint8Array([9, 9]), + }); + + await settle(); + await settle(); + expect(keys).toEqual([new Uint8Array([9, 9])]); + expect(worker.messages.at(-1)).toEqual({ + kind: "subscriptionItem", + subId: 4, + value: new Uint8Array([1]), + }); + + provider.dispose(); + }); + + it("never falls through unknown subscription names to another callback", async () => { + const worker = new FakeWorker(); + let preimageStarts = 0; + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + preimage: { + lookupPreimage: (() => { + preimageStarts += 1; + return () => {}; + }) as unknown as PreimageHost["lookupPreimage"], + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "subscriptionStart", + subId: 5, + name: "someFutureSubscribe", + payload: new Uint8Array([1, 2, 3]), + }); + + expect(preimageStarts).toBe(0); + expect(worker.messages.some((m) => m.kind === "subscriptionItem")).toBe( + false, + ); + + provider.dispose(); + }); + + it("does not dispatch a payload-carrying subscription without payload", async () => { + const worker = new FakeWorker(); + let preimageStarts = 0; + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + preimage: { + lookupPreimage: (() => { + preimageStarts += 1; + return () => {}; + }) as unknown as PreimageHost["lookupPreimage"], + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "subscriptionStart", + subId: 6, + name: "lookupPreimage", + payload: null, + }); + + expect(preimageStarts).toBe(0); + + provider.dispose(); + }); + + it("rejects when init times out", async () => { + const worker = new FakeWorker(); + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks(), + { + runtimeConfig: runtimeConfig(), + initTimeoutMs: 20, + }, + ); + worker.emit({ kind: "loaded" }); + await expect(providerPromise).rejects.toThrow( + /worker init timed out after 20ms/, + ); + expect(worker.terminated).toBe(true); + }); + + it("rejects on messageerror during init", async () => { + const worker = new FakeWorker(); + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks(), + { + runtimeConfig: runtimeConfig(), + }, + ); + worker.emitMessageError(); + await expect(providerPromise).rejects.toThrow(/could not be deserialized/); + expect(worker.terminated).toBe(true); + }); + + it("decodes raw v01 push notification payloads", async () => { + let notification: HostPushNotificationRequest | undefined; + const callbacks = createWasmRawCallbacks( + makeHostCallbacks({ + notifications: { + pushNotification: async (request) => { + notification = request; + return { id: 42 }; + }, + }, + }), + ); + + const encoded = await callbacks.pushNotification!( + HostPushNotificationRequest.enc({ + text: "Hello!", + deeplink: undefined, + scheduledAt: undefined, + }), + ); + + expect(HostPushNotificationResponse.dec(encoded).id).toBe(42); + expect(notification).toEqual({ + text: "Hello!", + deeplink: undefined, + scheduledAt: undefined, + }); + }); +}); diff --git a/js/packages/truapi-host/src/worker-permission-authorization.test.ts b/js/packages/truapi-host/src/worker-permission-authorization.test.ts new file mode 100644 index 00000000..adb49080 --- /dev/null +++ b/js/packages/truapi-host/src/worker-permission-authorization.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "bun:test"; + +import type { PermissionAuthorizationStatus } from "./runtime.js"; +import type { WorkerToMain } from "./worker-protocol.js"; +import { + handleGetPermissionAuthorizationStatus, + handleGetPermissionAuthorizationStatuses, + handleSetPermissionAuthorizationStatus, + type PermissionAuthorizationRuntime, +} from "./worker-permission-authorization.js"; + +const PRODUCT_ID = "playground.dot"; + +function recordMessages() { + const messages: WorkerToMain[] = []; + return { + messages, + postToMain(msg: WorkerToMain): void { + messages.push(msg); + }, + }; +} + +function makeRuntime( + overrides: Partial = {}, +): PermissionAuthorizationRuntime { + return { + permissionAuthorizationStatus: async () => "NotDetermined", + permissionAuthorizationStatuses: async (productId, requests) => { + void productId; + return requests.map(() => "NotDetermined"); + }, + setPermissionAuthorizationStatus: async () => {}, + ...overrides, + }; +} + +describe("worker permission authorization handlers", () => { + it("responds with a single permission authorization status", async () => { + const { messages, postToMain } = recordMessages(); + const request = new Uint8Array([1, 2, 3]); + const calls: { productId: string; request: Uint8Array }[] = []; + const runtime = makeRuntime({ + permissionAuthorizationStatus: async (productId, receivedRequest) => { + calls.push({ productId, request: receivedRequest }); + return "Authorized"; + }, + }); + + await handleGetPermissionAuthorizationStatus( + runtime, + postToMain, + PRODUCT_ID, + 7, + request, + ); + + expect(calls).toEqual([{ productId: PRODUCT_ID, request }]); + expect(messages).toEqual([ + { + kind: "permissionAuthorizationStatusResponse", + requestId: 7, + ok: true, + status: "Authorized", + }, + ]); + }); + + it("responds with batched permission authorization statuses", async () => { + const { messages, postToMain } = recordMessages(); + const requests = [new Uint8Array([1]), new Uint8Array([2])]; + const statuses: PermissionAuthorizationStatus[] = ["Denied", "Authorized"]; + const calls: { productId: string; requests: Uint8Array[] }[] = []; + const runtime = makeRuntime({ + permissionAuthorizationStatuses: async (productId, receivedRequests) => { + calls.push({ productId, requests: receivedRequests }); + return statuses; + }, + }); + + await handleGetPermissionAuthorizationStatuses( + runtime, + postToMain, + PRODUCT_ID, + 8, + requests, + ); + + expect(calls).toEqual([{ productId: PRODUCT_ID, requests }]); + expect(messages).toEqual([ + { + kind: "permissionAuthorizationStatusesResponse", + requestId: 8, + ok: true, + statuses, + }, + ]); + }); + + it("responds after setting a permission authorization status", async () => { + const { messages, postToMain } = recordMessages(); + const request = new Uint8Array([9, 10]); + const calls: { + productId: string; + request: Uint8Array; + status: PermissionAuthorizationStatus; + }[] = []; + const runtime = makeRuntime({ + setPermissionAuthorizationStatus: async ( + productId, + receivedRequest, + status, + ) => { + calls.push({ productId, request: receivedRequest, status }); + }, + }); + + await handleSetPermissionAuthorizationStatus( + runtime, + postToMain, + PRODUCT_ID, + 9, + request, + "Denied", + ); + + expect(calls).toEqual([ + { productId: PRODUCT_ID, request, status: "Denied" }, + ]); + expect(messages).toEqual([ + { + kind: "setPermissionAuthorizationStatusResponse", + requestId: 9, + ok: true, + }, + ]); + }); + + it("reports permission authorization requests received before runtime is ready", async () => { + const { messages, postToMain } = recordMessages(); + const request = new Uint8Array([1]); + + await handleGetPermissionAuthorizationStatus( + null, + postToMain, + PRODUCT_ID, + 1, + request, + ); + await handleGetPermissionAuthorizationStatuses( + null, + postToMain, + PRODUCT_ID, + 2, + [request], + ); + await handleSetPermissionAuthorizationStatus( + null, + postToMain, + PRODUCT_ID, + 3, + request, + "Authorized", + ); + + expect(messages).toEqual([ + { + kind: "permissionAuthorizationStatusResponse", + requestId: 1, + ok: false, + error: "permissionAuthorizationStatus received before runtime is ready", + }, + { + kind: "permissionAuthorizationStatusesResponse", + requestId: 2, + ok: false, + error: + "permissionAuthorizationStatuses received before runtime is ready", + }, + { + kind: "setPermissionAuthorizationStatusResponse", + requestId: 3, + ok: false, + error: + "setPermissionAuthorizationStatus received before runtime is ready", + }, + ]); + }); +}); diff --git a/js/packages/truapi-host/src/worker-permission-authorization.ts b/js/packages/truapi-host/src/worker-permission-authorization.ts new file mode 100644 index 00000000..5e1b03c4 --- /dev/null +++ b/js/packages/truapi-host/src/worker-permission-authorization.ts @@ -0,0 +1,129 @@ +import type { PermissionAuthorizationStatus } from "./runtime.js"; +import type { WorkerToMain } from "./worker-protocol.js"; +import { errorMessage } from "./error.js"; + +export interface PermissionAuthorizationRuntime { + permissionAuthorizationStatus( + productId: string, + request: Uint8Array, + ): Promise; + permissionAuthorizationStatuses( + productId: string, + requests: Uint8Array[], + ): Promise; + setPermissionAuthorizationStatus( + productId: string, + request: Uint8Array, + status: PermissionAuthorizationStatus, + ): Promise; +} + +type PostToMain = (msg: WorkerToMain) => void; + +export async function handleGetPermissionAuthorizationStatus( + runtime: PermissionAuthorizationRuntime | null, + postToMain: PostToMain, + productId: string, + requestId: number, + request: Uint8Array, +): Promise { + if (!runtime) { + postToMain({ + kind: "permissionAuthorizationStatusResponse", + requestId, + ok: false, + error: "permissionAuthorizationStatus received before runtime is ready", + }); + return; + } + try { + const status = await runtime.permissionAuthorizationStatus( + productId, + request, + ); + postToMain({ + kind: "permissionAuthorizationStatusResponse", + requestId, + ok: true, + status, + }); + } catch (err) { + postToMain({ + kind: "permissionAuthorizationStatusResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +export async function handleGetPermissionAuthorizationStatuses( + runtime: PermissionAuthorizationRuntime | null, + postToMain: PostToMain, + productId: string, + requestId: number, + requests: Uint8Array[], +): Promise { + if (!runtime) { + postToMain({ + kind: "permissionAuthorizationStatusesResponse", + requestId, + ok: false, + error: "permissionAuthorizationStatuses received before runtime is ready", + }); + return; + } + try { + const statuses = await runtime.permissionAuthorizationStatuses( + productId, + requests, + ); + postToMain({ + kind: "permissionAuthorizationStatusesResponse", + requestId, + ok: true, + statuses, + }); + } catch (err) { + postToMain({ + kind: "permissionAuthorizationStatusesResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +export async function handleSetPermissionAuthorizationStatus( + runtime: PermissionAuthorizationRuntime | null, + postToMain: PostToMain, + productId: string, + requestId: number, + request: Uint8Array, + status: PermissionAuthorizationStatus, +): Promise { + if (!runtime) { + postToMain({ + kind: "setPermissionAuthorizationStatusResponse", + requestId, + ok: false, + error: "setPermissionAuthorizationStatus received before runtime is ready", + }); + return; + } + try { + await runtime.setPermissionAuthorizationStatus(productId, request, status); + postToMain({ + kind: "setPermissionAuthorizationStatusResponse", + requestId, + ok: true, + }); + } catch (err) { + postToMain({ + kind: "setPermissionAuthorizationStatusResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} diff --git a/js/packages/truapi-host/src/worker-protocol.ts b/js/packages/truapi-host/src/worker-protocol.ts new file mode 100644 index 00000000..2b1a73c2 --- /dev/null +++ b/js/packages/truapi-host/src/worker-protocol.ts @@ -0,0 +1,164 @@ +// Wire format between the main thread (`createWebWorkerPairingHostRuntime`) and the +// Web Worker that hosts the truapi-server WASM runtime. +// +// Main window / host JS +// ┌─────────────────────────────────────────────────────────────────┐ +// │ createWebWorkerPairingHostRuntime │ +// │ host callbacks: storage, DOM prompts, chain provider, logging │ +// └───────────────┬─────────────────────────────────────────────────┘ +// │ MainToWorker: init, createCore, frame, +// │ callbackResponse, subscriptionItem, +// │ chainResponse +// v +// Dedicated Worker +// ┌─────────────────────────────────────────────────────────────────┐ +// │ shared truapi-server WASM PairingHostRuntime + product runtimes │ +// │ generated raw-callback proxy │ +// └───────────────┬─────────────────────────────────────────────────┘ +// │ WorkerToMain: coreReady, frame, callbackRequest, +// │ subscriptionStart, chainConnect +// v +// Main window dispatches those requests to the actual host callbacks. +// +// Frames (`kind: 'frame'`) carry SCALE-encoded `ProtocolMessage` bytes +// untouched in either direction. Everything else is a control message +// for callback dispatch, subscription bookkeeping, or chain connections. +// +// Frame bytes cross the boundary by structured clone, deliberately not as +// transferables: the sender keeps using its buffer (the worker side posts +// views into WASM memory) and frames are small, so the copy is the simpler +// safe choice. + +import type { LogLevel, PermissionAuthorizationStatus } from "./runtime.js"; +import type { + CallbackName, + SubscriptionName, +} from "./generated/worker-callbacks.js"; +/** + * Generated callback-name unions used by the worker transport. They keep the + * hand-written protocol aligned with the Rust platform callback catalog. + */ +export type { + CallbackName, + SubscriptionName, +} from "./generated/worker-callbacks.js"; + +/** + * Positional arguments for a callback. The wasm core calls each callback + * at a fixed arity; a uniform `unknown[]` keeps the wire protocol simple. + */ +export type CallbackArgs = readonly unknown[]; + +/** + * Messages posted by the main window to the WASM worker. These either control + * worker/core lifecycle, forward encoded TrUAPI frames into the core, or return + * host callback/subscription/chain responses requested by the worker. + */ +export type MainToWorker = + | { kind: "init"; logLevel: LogLevel; hostConfig: unknown } + | { kind: "createCore"; coreId: number; product: unknown } + | { kind: "disposeCore"; coreId: number } + | { kind: "setLogLevel"; level: LogLevel } + | { kind: "frame"; coreId: number; bytes: Uint8Array } + | { kind: "disconnectSession"; requestId: number } + | { kind: "cancelPairing" } + | { kind: "notifySessionStoreChanged" } + | { + kind: "getPermissionAuthorizationStatus"; + productId: string; + requestId: number; + request: Uint8Array; + } + | { + kind: "getPermissionAuthorizationStatuses"; + productId: string; + requestId: number; + requests: Uint8Array[]; + } + | { + kind: "setPermissionAuthorizationStatus"; + productId: string; + requestId: number; + request: Uint8Array; + status: PermissionAuthorizationStatus; + } + | { kind: "callbackResponse"; requestId: number; ok: true; value: unknown } + | { kind: "callbackResponse"; requestId: number; ok: false; error: string } + | { kind: "subscriptionItem"; subId: number; value: unknown } + | { kind: "chainConnectAck"; connId: number; ok: true } + | { kind: "chainConnectAck"; connId: number; ok: false; error: string } + | { kind: "chainResponse"; connId: number; json: string } + | { kind: "dispose" }; + +/** + * Messages posted by the WASM worker back to the main window. These either + * report worker lifecycle/errors, emit encoded TrUAPI frames from the core, or + * request host callbacks, subscriptions, and chain-provider operations. + */ +export type WorkerToMain = + | { kind: "loaded" } + | { kind: "ready" } + | { kind: "coreReady"; coreId: number } + | { kind: "coreError"; coreId: number; error: string } + | { kind: "fatalError"; error: string } + | { kind: "frameError"; coreId: number; error: string } + | { kind: "disposeError"; error: string } + | { kind: "frame"; coreId: number; bytes: Uint8Array } + | { kind: "disconnectSessionResponse"; requestId: number; ok: true } + | { + kind: "disconnectSessionResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "permissionAuthorizationStatusResponse"; + requestId: number; + ok: true; + status: PermissionAuthorizationStatus; + } + | { + kind: "permissionAuthorizationStatusResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "permissionAuthorizationStatusesResponse"; + requestId: number; + ok: true; + statuses: PermissionAuthorizationStatus[]; + } + | { + kind: "permissionAuthorizationStatusesResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "setPermissionAuthorizationStatusResponse"; + requestId: number; + ok: true; + } + | { + kind: "setPermissionAuthorizationStatusResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "callbackRequest"; + requestId: number; + name: CallbackName; + args: CallbackArgs; + } + | { + kind: "subscriptionStart"; + subId: number; + name: SubscriptionName; + payload: Uint8Array | null; + } + | { kind: "subscriptionStop"; subId: number } + | { kind: "chainConnectStart"; connId: number; genesisHash: string } + | { kind: "chainSend"; connId: number; request: string } + | { kind: "chainClose"; connId: number }; diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts new file mode 100644 index 00000000..8d360932 --- /dev/null +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -0,0 +1,407 @@ +/// +// Worker entrypoint. Loads the web-targeted truapi-server WASM bundle and +// bridges every host callback over postMessage. The main thread keeps the +// state that needs DOM access (localStorage, prompts) while the core dispatcher +// runs here off the page main thread. + +import type { + MainToWorker, + SubscriptionName, + WorkerToMain, +} from "./worker-protocol.js"; +import { + createWorkerRawCallbacks, + type CallbackName, +} from "./generated/worker-callbacks.js"; +import { + handleGetPermissionAuthorizationStatus, + handleGetPermissionAuthorizationStatuses, + handleSetPermissionAuthorizationStatus, + type PermissionAuthorizationRuntime, +} from "./worker-permission-authorization.js"; +import { errorMessage } from "./error.js"; + +interface WorkerProductRuntime { + receiveFrame(frame: Uint8Array): Promise; + dispose(): void; + free(): void; +} + +interface WorkerPairingHostRuntime extends PermissionAuthorizationRuntime { + productRuntime( + product: unknown, + coreCallbacks: unknown, + ): WorkerProductRuntime; + disconnectSession(): Promise; + cancelPairing(): void; + notifySessionStoreChanged(): void; + free(): void; +} + +interface WasmModuleShape { + default: (input?: unknown) => Promise; + WasmPairingHostRuntime: new ( + callbacks: unknown, + hostConfig: unknown, + ) => WorkerPairingHostRuntime; + WasmProductRuntime: new ( + callbacks: unknown, + runtimeConfig: unknown, + ) => WorkerProductRuntime; + setLogLevel?: (level: string) => void; +} + +// Resolved at runtime, the wasm-pack artifact lives outside `src/` so a +// static import would leak into the TS rootDir. The relative path is +// resolved against `dist/worker-runtime.js` once compiled. Indirected +// through a variable so TS skips the static module-existence check. +const WASM_WEB_PATH = "./wasm/web/truapi_server.js"; +const wasmModulePromise = import( + /* @vite-ignore */ WASM_WEB_PATH +) as Promise; + +const ctx = self as unknown as DedicatedWorkerGlobalScope; + +function postToMain(msg: WorkerToMain): void { + ctx.postMessage(msg); +} + +let nextRequestId = 0; +const pendingCallbacks = new Map< + number, + (result: { ok: true; value: unknown } | { ok: false; error: string }) => void +>(); + +let nextSubId = 0; +const subscriptionItemListeners = new Map void>(); + +let nextConnId = 0; +type ChainConnectAck = { ok: true } | { ok: false; error: string }; +const chainConnectAcks = new Map void>(); +const chainResponseListeners = new Map void>(); + +function callbackRequest( + name: CallbackName, + args: readonly unknown[], +): Promise { + return new Promise((resolve, reject) => { + const requestId = ++nextRequestId; + pendingCallbacks.set(requestId, (r) => { + if (r.ok) resolve(r.value); + else reject(new Error(r.error)); + }); + postToMain({ kind: "callbackRequest", requestId, name, args }); + }); +} + +function startSubscription( + name: SubscriptionName, + payload: Uint8Array | null, + sendItem: (value: T) => void, +): () => void { + const subId = ++nextSubId; + subscriptionItemListeners.set(subId, sendItem as (value: unknown) => void); + postToMain({ kind: "subscriptionStart", subId, name, payload }); + return () => { + subscriptionItemListeners.delete(subId); + postToMain({ kind: "subscriptionStop", subId }); + }; +} + +interface WorkerChainConnection { + send(request: string): void; + close(): void; +} + +/** + * Worker-side half of the host chain-connect bridge. + * + * The Rust core runs in this worker but owns no socket. When it needs chain + * access (chainHead v1 for People-chain identity / statement-store SSO) it + * calls this; the actual transport lives on the host main thread and is reached + * over postMessage. The data crossing here is JSON-RPC strings, not SCALE: only + * the product<->core wire is SCALE. + * + * per-tab / sandboxed core-owned (this Web Worker) host-owned (main thread) + * +-------------------+ SCALE +--------------------------+ +--------------------------------+ + * | Product (iframe) |<------->| truapi-server WASM core | | host.connect() (ChainProvider) | + * | speaks TrUAPI | frames | chainHead v1, SSO, | | host-owned JSON-RPC transport | + * | never sees chains | | People-chain identity | | remote RPC, native client, ... | + * +-------------------+ +--------------------------+ +--------------------------------+ + * | ^ JSON-RPC strings (not SCALE) ^ | + * chainConnect() | | onResponse(json) connect | | responses() + * (this fn) v | | v + * worker-runtime.ts <======== postMessage ========> create-worker-host-runtime.ts + * chainConnectStart / chainSend / chainClose --> handleChainConnect* -> host.connect() + * chainConnectAck / chainResponse <-- (pumped from connection.responses()) + * + * Allocates a `connId`, posts `chainConnectStart`, and resolves a + * `{ send, close }` handle once the main thread acks. `send` posts `chainSend`, + * `close` posts `chainClose`, and every `chainResponse` for this `connId` is + * delivered to `onResponse`. + */ +function chainConnect( + genesisHash: string, + onResponse: (json: string) => void, +): Promise { + const connId = ++nextConnId; + return new Promise((resolve, reject) => { + chainConnectAcks.set(connId, (ack) => { + if (!ack.ok) { + chainResponseListeners.delete(connId); + reject(new Error(ack.error)); + return; + } + resolve({ + send(request: string) { + postToMain({ kind: "chainSend", connId, request }); + }, + close() { + chainResponseListeners.delete(connId); + postToMain({ kind: "chainClose", connId }); + }, + }); + }); + chainResponseListeners.set(connId, onResponse); + postToMain({ kind: "chainConnectStart", connId, genesisHash }); + }); +} + +/** Build the host-level callback object passed to the WASM runtime. */ +function buildRawCallbacks() { + return createWorkerRawCallbacks({ + callbackRequest, + startSubscription, + chainConnect, + }); +} + +function buildCoreCallbacks(coreId: number) { + return { + emitFrame(frame: Uint8Array): void { + postToMain({ kind: "frame", coreId, bytes: frame }); + }, + dispose(): void { + // Main thread owns lifecycle and disposes explicitly. + }, + }; +} + +let runtime: WorkerPairingHostRuntime | null = null; +const cores = new Map(); +let wasm: WasmModuleShape | null = null; + +(async () => { + try { + wasm = await wasmModulePromise; + await wasm.default(); + postToMain({ kind: "loaded" }); + } catch (err) { + postToMain({ kind: "fatalError", error: errorMessage(err) }); + } +})(); + +ctx.addEventListener("message", (ev: MessageEvent) => { + const msg = ev.data; + switch (msg.kind) { + case "init": + if (!wasm) { + postToMain({ + kind: "fatalError", + error: "init received before WASM loaded", + }); + break; + } + if (runtime) { + postToMain({ + kind: "fatalError", + error: "init: runtime already initialized", + }); + break; + } + wasm.setLogLevel?.(msg.logLevel); + try { + runtime = new wasm.WasmPairingHostRuntime( + buildRawCallbacks(), + msg.hostConfig, + ); + postToMain({ kind: "ready" }); + } catch (err) { + postToMain({ kind: "fatalError", error: `init: ${errorMessage(err)}` }); + } + break; + case "createCore": + if (!runtime) { + postToMain({ + kind: "coreError", + coreId: msg.coreId, + error: "createCore received before runtime is ready", + }); + break; + } + try { + const core = runtime.productRuntime( + msg.product, + buildCoreCallbacks(msg.coreId), + ); + cores.set(msg.coreId, core); + postToMain({ kind: "coreReady", coreId: msg.coreId }); + } catch (err) { + postToMain({ + kind: "coreError", + coreId: msg.coreId, + error: errorMessage(err), + }); + } + break; + case "setLogLevel": + wasm?.setLogLevel?.(msg.level); + break; + case "frame": + void handleFrame(msg.coreId, msg.bytes); + break; + case "disconnectSession": + void handleDisconnectSession(msg.requestId); + break; + case "cancelPairing": + runtime?.cancelPairing(); + break; + case "notifySessionStoreChanged": + runtime?.notifySessionStoreChanged(); + break; + case "getPermissionAuthorizationStatus": + void handleGetPermissionAuthorizationStatus( + runtime, + postToMain, + msg.productId, + msg.requestId, + msg.request, + ); + break; + case "getPermissionAuthorizationStatuses": + void handleGetPermissionAuthorizationStatuses( + runtime, + postToMain, + msg.productId, + msg.requestId, + msg.requests, + ); + break; + case "setPermissionAuthorizationStatus": + void handleSetPermissionAuthorizationStatus( + runtime, + postToMain, + msg.productId, + msg.requestId, + msg.request, + msg.status, + ); + break; + case "callbackResponse": { + const cb = pendingCallbacks.get(msg.requestId); + if (cb) { + pendingCallbacks.delete(msg.requestId); + cb( + msg.ok + ? { ok: true, value: msg.value } + : { ok: false, error: msg.error }, + ); + } + break; + } + case "subscriptionItem": { + const listener = subscriptionItemListeners.get(msg.subId); + if (listener) listener(msg.value); + break; + } + case "chainConnectAck": { + const cb = chainConnectAcks.get(msg.connId); + if (cb) { + chainConnectAcks.delete(msg.connId); + cb(msg.ok ? { ok: true } : { ok: false, error: msg.error }); + } + break; + } + case "chainResponse": { + const listener = chainResponseListeners.get(msg.connId); + if (listener) listener(msg.json); + break; + } + case "disposeCore": + disposeCore(msg.coreId); + break; + case "dispose": + try { + for (const coreId of [...cores.keys()]) { + disposeCore(coreId); + } + runtime?.free(); + } catch (err) { + postToMain({ kind: "disposeError", error: errorMessage(err) }); + } + runtime = null; + break; + default: { + const { kind } = msg as { kind?: unknown }; + console.warn( + `[truapi worker-runtime] unknown message kind: ${String(kind)}`, + ); + } + } +}); + +function disposeCore(coreId: number): void { + const core = cores.get(coreId); + if (!core) return; + cores.delete(coreId); + try { + core.dispose(); + core.free(); + } catch (err) { + postToMain({ kind: "disposeError", error: errorMessage(err) }); + } +} + +async function handleDisconnectSession(requestId: number): Promise { + if (!runtime) { + postToMain({ + kind: "disconnectSessionResponse", + requestId, + ok: false, + error: "disconnectSession received before runtime is ready", + }); + return; + } + try { + await runtime.disconnectSession(); + postToMain({ kind: "disconnectSessionResponse", requestId, ok: true }); + } catch (err) { + postToMain({ + kind: "disconnectSessionResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleFrame(coreId: number, bytes: Uint8Array): Promise { + const core = cores.get(coreId); + if (!core) { + postToMain({ + kind: "frameError", + coreId, + error: `frame received for unknown core ${coreId}`, + }); + return; + } + try { + await core.receiveFrame(bytes); + } catch (err) { + postToMain({ + kind: "frameError", + coreId, + error: errorMessage(err), + }); + } +} diff --git a/js/packages/truapi-host/tsconfig.json b/js/packages/truapi-host/tsconfig.json new file mode 100644 index 00000000..9d2dcad8 --- /dev/null +++ b/js/packages/truapi-host/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "composite": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM", "WebWorker"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/test-support.ts"], + "references": [{ "path": "../truapi" }] +} diff --git a/js/packages/truapi/package.json b/js/packages/truapi/package.json index 6b8e2f61..db961565 100644 --- a/js/packages/truapi/package.json +++ b/js/packages/truapi/package.json @@ -66,7 +66,7 @@ }, "scripts": { "ensure-generated": "./scripts/ensure-generated.sh", - "build": "tsc", + "build": "tsc -b", "prebuild": "npm run ensure-generated", "codegen": "cargo run -p truapi-codegen -- --input ../../../target/doc/truapi.json --output src/generated --playground-output src/playground --explorer-output src/explorer", "typecheck": "npm run build", 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/js/packages/truapi/src/client.ts b/js/packages/truapi/src/client.ts index fc13022d..f45481f4 100644 --- a/js/packages/truapi/src/client.ts +++ b/js/packages/truapi/src/client.ts @@ -13,13 +13,15 @@ import { type WireProvider, } from "./transport.js"; import { + CallError, indexedTaggedUnion, Result, _void, + type CallErrorValue, type Codec, type ResultPayload, } from "./scale.js"; -import { TRUAPI_CODEC_VERSION, TRUAPI_VERSION } from "./generated/client.js"; +import { TRUAPI_CODEC_VERSION } from "./generated/client.js"; import * as T from "./generated/types.js"; import * as W from "./generated/wire-table.js"; @@ -29,13 +31,12 @@ export type { Subscription, TrUApiTransport }; * Version overrides used when constructing a transport. */ export interface CreateTransportOptions { - /** - * Highest TrUAPI protocol version exposed by the transport. - */ - truapiVersion?: number; - /** * SCALE codec version advertised during host handshake negotiation. + * + * @deprecated TODO(shared-core-wire): remove this override with + * `TrUApiTransport.codecVersion` once generated handshake requests use + * `TRUAPI_CODEC_VERSION` directly. */ codecVersion?: number; } @@ -51,7 +52,10 @@ function protocolVersionTag(version: number): `V${number}` { return `V${version}` as `V${number}`; } -type HandshakeResponse = ResultPayload; +type HandshakeResponse = ResultPayload< + undefined, + CallErrorValue +>; const HANDSHAKE_WIRE_VERSION = 1; /** @@ -63,7 +67,7 @@ function handshakeResponseCodec( return indexedTaggedUnion({ [protocolVersionTag(version)]: [ version - 1, - Result(_void, T.HostHandshakeError), + Result(_void, CallError(T.VersionedHostHandshakeError)), ] as const, }) as Codec<{ tag: `V${number}`; value: HandshakeResponse }>; } @@ -90,8 +94,14 @@ function encodeUnsupportedHandshakeResponse(version: number): Uint8Array { value: { success: false, value: { - tag: "UnsupportedProtocolVersion", - value: undefined, + tag: "Domain", + value: { + tag: "V1", + value: { + tag: "UnsupportedProtocolVersion", + value: undefined, + }, + }, }, }, }); @@ -140,7 +150,6 @@ export function createTransport( provider: WireProvider, options: CreateTransportOptions = {}, ): TrUApiTransport { - const truapiVersion = options.truapiVersion ?? TRUAPI_VERSION; const codecVersion = options.codecVersion ?? TRUAPI_CODEC_VERSION; let idCounter = 0; let closedError: Error | null = null; @@ -305,7 +314,6 @@ export function createTransport( } return { - truapiVersion, codecVersion, /** * Send one request frame and resolve with the typed Ok/Err outcome diff --git a/js/packages/truapi/src/sandbox.ts b/js/packages/truapi/src/sandbox.ts index 51861e4e..702cda26 100644 --- a/js/packages/truapi/src/sandbox.ts +++ b/js/packages/truapi/src/sandbox.ts @@ -10,11 +10,7 @@ * @module */ -import { - createIframeProvider, - createMessagePortProvider, - type WireProvider, -} from "./transport.js"; +import { createMessagePortProvider, type WireProvider } from "./transport.js"; import { createTransport } from "./client.js"; import { createClient, type TrUApiClient } from "./generated/index.js"; @@ -62,10 +58,7 @@ export function isCorrectEnvironment(): boolean { } /** - * Origin used as the `targetOrigin` for outbound `postMessage` frames. Frames - * carry signed payloads and account ids, so this fails closed: when no concrete - * origin can be pinned it returns `null` (rather than falling back to `"*"`) and - * provider construction throws. + * Origin used as the `targetOrigin` for iframe bootstrap messages. */ function resolveHostOrigin(): string | null { if (typeof document !== "undefined" && document.referrer) { @@ -80,7 +73,8 @@ function resolveHostOrigin(): string | null { return null; } -const WEBVIEW_PORT_TIMEOUT_MS = 20_000; +const HOST_PORT_TIMEOUT_MS = 20_000; +let iframePortPromise: Promise | null = null; /** * Resolve the host-injected `MessagePort`, polling `window.__HOST_API_PORT__` @@ -93,7 +87,7 @@ const WEBVIEW_PORT_TIMEOUT_MS = 20_000; */ async function waitForWebviewPort( signal?: AbortSignal, - timeoutMs = WEBVIEW_PORT_TIMEOUT_MS, + timeoutMs = HOST_PORT_TIMEOUT_MS, ): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { @@ -107,21 +101,86 @@ async function waitForWebviewPort( ); } -/** Build the {@link WireProvider} matching the detected environment (iframe or webview). */ -function createSandboxProvider(): WireProvider { - if (isIframe()) { +/** + * Resolve the iframe `MessagePort` transferred by `createIframeHost`. + */ +function waitForIframePort( + signal?: AbortSignal, + timeoutMs = HOST_PORT_TIMEOUT_MS, +): Promise { + const existing = hostWindow()?.__HOST_API_PORT__; + if (existing) return Promise.resolve(existing); + if (iframePortPromise) return iframePortPromise; + + iframePortPromise = new Promise((resolve, reject) => { + const win = hostWindow(); + if (!win) { + reject(new Error("window is unavailable")); + return; + } + const hostOrigin = resolveHostOrigin(); - if (!hostOrigin) { - throw new Error( - "TrUAPI iframe provider could not resolve the host origin from document.referrer / ancestorOrigins.", + let done = false; + const cleanup = (): void => { + win.removeEventListener("message", onMessage); + signal?.removeEventListener("abort", onAbort); + clearTimeout(timer); + }; + const finish = (result: MessagePort | Error): void => { + if (done) return; + done = true; + cleanup(); + if (result instanceof Error) { + reject(result); + } else { + win.__HOST_API_PORT__ = result; + resolve(result); + } + }; + const onAbort = (): void => { + finish(new Error("waitForIframePort aborted")); + }; + const onMessage = (event: MessageEvent): void => { + if (event.source !== win.parent) return; + if ( + hostOrigin !== null && + event.origin !== hostOrigin && + event.origin !== "null" + ) { + return; + } + if (event.data?.type !== "truapi-init") return; + const [port] = event.ports; + if (!port) { + finish(new Error("truapi-init did not include a MessagePort")); + return; + } + finish(port); + }; + const timer = setTimeout(() => { + finish( + new Error(`Timed out waiting for iframe MessagePort (${timeoutMs}ms)`), ); - } - return createIframeProvider({ target: window.parent, hostOrigin }); - } + }, timeoutMs); + + win.addEventListener("message", onMessage); + signal?.addEventListener("abort", onAbort, { once: true }); + win.parent.postMessage({ type: "truapi-ready" }, hostOrigin ?? "*"); + }).catch((error: unknown) => { + iframePortPromise = null; + throw error; + }); + + return iframePortPromise; +} + +/** Build the {@link WireProvider} matching the detected environment (iframe or webview). */ +function createSandboxProvider(): WireProvider { const portController = new AbortController(); - const provider = createMessagePortProvider( - waitForWebviewPort(portController.signal), - ); + const portPromise = isIframe() + ? waitForIframePort(portController.signal) + : waitForWebviewPort(portController.signal); + const provider = createMessagePortProvider(portPromise); const baseDispose = provider.dispose; provider.dispose = () => { portController.abort(); @@ -166,14 +225,21 @@ export function getClientSync(): TrUApiClient | null { export function subscribeConnectionStatus( callback: (status: ConnectionStatus) => void, ): () => void { - statusListeners.add(callback); - callback(status); + let emitted = false; + const listener = (next: ConnectionStatus) => { + emitted = true; + callback(next); + }; + statusListeners.add(listener); if (status === "disconnected") { setStatus(getClientSync() ? "connected" : "disconnected"); } + if (!emitted) { + callback(status); + } return () => { - statusListeners.delete(callback); + statusListeners.delete(listener); }; } diff --git a/js/packages/truapi/src/transport.ts b/js/packages/truapi/src/transport.ts index 0f9ad4de..926ec77b 100644 --- a/js/packages/truapi/src/transport.ts +++ b/js/packages/truapi/src/transport.ts @@ -201,12 +201,11 @@ export interface SubscribeRawParams { **/ export interface TrUApiTransport { /** - * Highest TrUAPI protocol version supported by this generated client. - **/ - readonly truapiVersion: number; - - /** - * SCALE codec version negotiated through the handshake. + * SCALE codec version used by generated handshake calls. + * + * @deprecated TODO(shared-core-wire): remove this public transport field once + * generated handshake requests read `TRUAPI_CODEC_VERSION` directly instead + * of going through transport state. **/ readonly codecVersion: number; diff --git a/js/packages/truapi/tsconfig.json b/js/packages/truapi/tsconfig.json index 79d35ea5..56a11d6f 100644 --- a/js/packages/truapi/tsconfig.json +++ b/js/packages/truapi/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", + "composite": true, "declaration": true, "outDir": "dist", "rootDir": "src", diff --git a/package-lock.json b/package-lock.json index 51905f9a..b7ff84bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,19 @@ "typescript": "^6.0" } }, + "js/packages/truapi-host": { + "name": "@parity/truapi-host", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@parity/truapi": "file:../truapi" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "neverthrow": "^8.2.0", + "typescript": "^5.7" + } + }, "js/packages/truapi/node_modules/typescript": { "version": "6.0.3", "dev": true, @@ -414,6 +427,10 @@ "resolved": "js/packages/truapi", "link": true }, + "node_modules/@parity/truapi-host": { + "resolved": "js/packages/truapi-host", + "link": true + }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", @@ -1173,6 +1190,20 @@ "node": ">=8.0" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", diff --git a/playground/package.json b/playground/package.json index 78fef5f3..bc1116a4 100644 --- a/playground/package.json +++ b/playground/package.json @@ -10,7 +10,7 @@ "prebuild": "node ./scripts/bundle-rxjs-dts.mjs", "build": "next build", "prelint": "node ./scripts/bundle-rxjs-dts.mjs", - "lint": "next lint --dir src --dir test/generated/examples && tsc --noEmit && tsc -p tsconfig.examples.json", + "lint": "eslint src test/generated/examples && tsc --noEmit && tsc -p tsconfig.examples.json", "pretypecheck": "node ./scripts/bundle-rxjs-dts.mjs", "typecheck": "tsc --noEmit", "typecheck:examples": "tsc -p tsconfig.examples.json", @@ -41,6 +41,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "^15.5.4", + "jsqr": "^1.4.0", "typescript": "^6.0" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" diff --git a/playground/playwright.config.ts b/playground/playwright.config.ts index b5f80bfc..9009ec7c 100644 --- a/playground/playwright.config.ts +++ b/playground/playwright.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ reporter: isCI ? [["github"], ["html", { open: "never" }]] : "list", use: { baseURL: "http://localhost:5173", + serviceWorkers: "block", trace: "retain-on-failure", screenshot: "only-on-failure", video: "retain-on-failure", @@ -23,11 +24,13 @@ export default defineConfig({ ], webServer: [ { - // dotli host iframe shell at :5173. `bun run preview` runs - // `turbo run build && bun scripts/preview-server.ts`, so a cold - // CI runner needs the long timeout. - command: "bun run preview", + // dotli host iframe shell at :5173. Localhost product proxy routes are + // debug-build-only, so mirror `make dev` and run the debug preview. + command: "bun run preview:debug", cwd: "../hosts/dotli", + env: { + VITE_NETWORKS: process.env.VITE_NETWORKS ?? "paseo-next-v2,previewnet", + }, url: "http://localhost:5173", reuseExistingServer: !isCI, timeout: 10 * 60 * 1000, diff --git a/playground/src/lib/auto-test.ts b/playground/src/lib/auto-test.ts index 617072d8..18c4a987 100644 --- a/playground/src/lib/auto-test.ts +++ b/playground/src/lib/auto-test.ts @@ -15,13 +15,16 @@ export interface TestEntry { const UNARY_TIMEOUT_MS = 10_000; const SIGNING_TIMEOUT_MS = 30_000; const SSO_TIMEOUT_MS = 60_000; +const LIVE_ALLOCATION_TIMEOUT_MS = 240_000; // Services skipped wholesale in the diagnosis until hosts wire them up. -const SKIPPED_SERVICES = new Set(["Coin Payment"]); -// Methods whose first call implicitly triggers a host permission/signing -// prompt, so they need the longer signing-class timeout to allow for the user -// to respond. `get_account_alias` and `Preimage/submit` prompt on first use. +const SKIPPED_SERVICES = new Set(["Chat", "Coin Payment", "Payment"]); +// Individual methods skipped while the host surface is intentionally deferred. +const SKIPPED_METHODS = new Set(["Account/create_account_proof"]); +// Methods that trigger a host permission/signing prompt, so they need the +// longer signing-class timeout to allow for the user to respond. const LONG_TIMEOUT_METHODS = new Set([ + "Account/get_account", "Account/get_account_alias", "Resource Allocation/request", "Signing/sign_payload", @@ -34,7 +37,15 @@ 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], + ["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], + ["Statement Store/subscribe", LIVE_ALLOCATION_TIMEOUT_MS], ]); type RunOneOpts = { @@ -43,6 +54,11 @@ type RunOneOpts = { onUpdate: (id: string, entry: TestEntry) => void; }; +type DiagnosisItem = { + serviceName: string; + method: MethodInfo; +}; + async function runOne({ serviceName, method, @@ -54,6 +70,10 @@ async function runOne({ onUpdate(id, { status: "skipped" }); return; } + if (SKIPPED_METHODS.has(id)) { + onUpdate(id, { status: "skipped" }); + return; + } if (!method.exampleSource) { onUpdate(id, { status: "fail", output: "no runnable example" }); return; @@ -128,19 +148,48 @@ export async function runSingleTest( await runOne({ serviceName, method, onUpdate }); } -// Full diagnosis: run every method one at a time, in service order. Methods -// that prompt the user (signing, permission/resource requests) block on their -// host dialog before the run continues. Produces a complete worked / failed / -// not-wired matrix suitable for the copy-pasteable report. +// Full diagnosis: run every method one at a time. Methods that prompt the user +// (signing, permission/resource requests) block on their host dialog before +// the run continues. Produces a complete worked / failed / not-wired matrix +// suitable for the copy-pasteable report. export async function runDiagnosis( services: ServiceInfo[], onUpdate: (id: string, entry: TestEntry) => void, signal?: AbortSignal, ): Promise { - for (const svc of services) { - for (const method of svc.methods) { - if (signal?.aborted) return; - await runOne({ serviceName: svc.name, method, onUpdate }); - } + const items = diagnosisRunItems(services); + for (const item of items) { + if (signal?.aborted) return; + await runOne({ ...item, onUpdate }); } } + +function diagnosisRunItems(services: ServiceInfo[]): DiagnosisItem[] { + const items = services.flatMap((svc) => + svc.methods.map((method) => ({ serviceName: svc.name, method })), + ); + moveBefore(items, "Resource Allocation/request", "Preimage/lookup_subscribe"); + return items; +} + +function moveBefore( + items: DiagnosisItem[], + itemId: string, + beforeId: string, +): void { + const currentIndex = items.findIndex( + (item) => diagnosisItemId(item) === itemId, + ); + const beforeIndex = items.findIndex( + (item) => diagnosisItemId(item) === beforeId, + ); + if (currentIndex < 0 || beforeIndex < 0 || currentIndex < beforeIndex) { + return; + } + const [item] = items.splice(currentIndex, 1); + items.splice(beforeIndex, 0, item); +} + +function diagnosisItemId(item: DiagnosisItem): string { + return `${item.serviceName}/${item.method.name}`; +} diff --git a/playground/tests/e2e/dotli-diagnosis.ts b/playground/tests/e2e/dotli-diagnosis.ts new file mode 100644 index 00000000..9ee9e4ce --- /dev/null +++ b/playground/tests/e2e/dotli-diagnosis.ts @@ -0,0 +1,774 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { + chromium, + type BrowserContext, + type Frame, + type Page, +} from "playwright"; +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { extractQrPayload } from "./dotli/helpers/extract-qr-payload"; +import { + disconnect, + generateUsername, + health, + pair, + type PairResult, +} from "./dotli/helpers/signer-bot"; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(currentDir, "../../.."); +const playgroundRoot = resolve(repoRoot, "playground"); +const dotliRoot = resolve(repoRoot, "hosts/dotli"); +const outputDir = resolve(playgroundRoot, "test-results/e2e-dotli"); +const screenshotsDir = resolve(outputDir, "screenshots"); + +const hostPort = process.env.E2E_DOTLI_HOST_PORT ?? process.env.PORT ?? "5173"; +const playgroundPort = process.env.E2E_DOTLI_PLAYGROUND_PORT ?? "3000"; +const headless = process.env.HEADED === "1" ? false : true; +const slowMo = process.env.SLOWMO ? Number(process.env.SLOWMO) : 0; +const smokeOnly = process.env.E2E_DOTLI_SMOKE === "1"; +const defaultBotBase = "https://signing-bot-dev.novasama-tech.org/"; +const defaultBotNetwork = "paseo-next-v2"; +const loginUserBadgeTimeoutMs = Number( + process.env.E2E_DOTLI_LOGIN_TIMEOUT_MS ?? "240000", +); + +const botToken = readEnv("SIGNER_BOT_SVC_TOKEN"); +const botBase = process.env.SIGNER_BOT_BASE_URL ?? defaultBotBase; +const botNetwork = process.env.SIGNER_BOT_NETWORK ?? defaultBotNetwork; + +const serverProcesses: ChildProcess[] = []; +const pageErrors: string[] = []; +const browserLogs: string[] = []; +const screenshots: string[] = []; +let screenshotSeq = 0; + +type PlaygroundE2E = { + waitForConnectionStatus?: ( + status: "disconnected" | "connecting" | "connected", + timeoutMs?: number, + ) => Promise; + startAccountConnectionStatusProbe?: unknown; +}; + +declare global { + interface Window { + __dotliE2eAuthStates?: unknown[]; + __truapiPlaygroundE2E?: PlaygroundE2E; + __TRUAPI_PLAYGROUND_E2E__?: boolean; + } +} + +function readEnv(name: string): string | undefined { + const value = process.env[name]; + if (!value && !smokeOnly) { + throw new Error( + `${name} is required for fully automated e2e-dotli. ` + + "This suite pairs through signer-bot; without it, a human phone scan is required.", + ); + } + return value; +} + +function requireBotEnv(): { + token: string; + base: string; + network: string; +} { + if (botToken === undefined) { + throw new Error( + "SIGNER_BOT_SVC_TOKEN is required outside E2E_DOTLI_SMOKE=1.", + ); + } + return { token: botToken, base: botBase, network: botNetwork }; +} + +function startServer( + label: string, + command: string, + args: string[], + cwd: string, + env: NodeJS.ProcessEnv = {}, +): ChildProcess { + const child = spawn(command, args, { + cwd, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + serverProcesses.push(child); + + const prefix = `[${label}]`; + child.stdout?.on("data", (chunk: Buffer) => { + process.stdout.write( + chunk + .toString() + .split("\n") + .map((line) => (line.length > 0 ? `${prefix} ${line}` : line)) + .join("\n"), + ); + }); + child.stderr?.on("data", (chunk: Buffer) => { + process.stderr.write( + chunk + .toString() + .split("\n") + .map((line) => (line.length > 0 ? `${prefix} ${line}` : line)) + .join("\n"), + ); + }); + child.on("exit", (code, signal) => { + if (code !== null && code !== 0) { + console.error(`${prefix} exited with code ${code}`); + } else if (signal !== null && signal !== "SIGTERM") { + console.error(`${prefix} exited via ${signal}`); + } + }); + return child; +} + +async function waitForHttp(url: string, label: string): Promise { + const deadline = Date.now() + 120_000; + let last = ""; + while (Date.now() < deadline) { + for (const child of serverProcesses) { + if (child.exitCode !== null) { + throw new Error(`${label} failed to start; a server process exited`); + } + } + try { + const response = await fetch(url, { method: "GET" }); + if (response.ok) { + return; + } + last = `${response.status} ${response.statusText}`; + } catch (error) { + last = error instanceof Error ? error.message : String(error); + } + await sleep(1_000); + } + throw new Error(`${label} did not become ready at ${url}: ${last}`); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function captureStep(page: Page, name: string): Promise { + const safeName = name.replace(/[^a-z0-9_-]+/gi, "-").toLowerCase(); + const filename = `${String(++screenshotSeq).padStart(2, "0")}-${safeName}.png`; + const path = resolve(screenshotsDir, filename); + mkdirSync(screenshotsDir, { recursive: true }); + await page + .screenshot({ path, fullPage: true }) + .then(() => { + screenshots.push(path); + console.log(`[e2e-dotli] screenshot: ${path}`); + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[e2e-dotli] screenshot failed (${name}): ${message}`); + }); +} + +async function assertPortsFree(): Promise { + await Promise.all([ + assertPortFree(Number(hostPort), "dotli preview"), + assertPortFree(Number(playgroundPort), "playground"), + ]); +} + +async function assertPortFree(port: number, label: string): Promise { + try { + await fetch(`http://127.0.0.1:${port}/`, { method: "GET" }); + } catch { + return; + } + throw new Error( + `${label} port ${port} is already serving HTTP. Stop the stale process or set ` + + `${label === "dotli preview" ? "E2E_DOTLI_HOST_PORT" : "E2E_DOTLI_PLAYGROUND_PORT"}.`, + ); +} + +async function startLocalStack(): Promise { + await assertPortsFree(); + startServer("dotli", "bun", ["run", "preview:debug"], dotliRoot, { + PORT: hostPort, + }); + startServer("playground", "yarn", ["dev"], playgroundRoot, { + PORT: playgroundPort, + }); + await Promise.all([ + waitForHttp(`http://localhost:${hostPort}/`, "dotli preview"), + waitForHttp(`http://localhost:${playgroundPort}/`, "playground"), + ]); +} + +async function signOutIfNeeded(page: Page): Promise { + const badge = page.locator("#auth-button .user-badge"); + if (!(await badge.isVisible({ timeout: 2_000 }).catch(() => false))) { + return; + } + console.log( + "[e2e-dotli] existing session found; signing out through host UI", + ); + await page.evaluate(() => { + document.querySelector("#auth-button")?.click(); + }); + await page.locator("#user-popover-disconnect").waitFor({ + state: "visible", + timeout: 5_000, + }); + await page.evaluate(() => { + document + .querySelector("#user-popover-disconnect") + ?.click(); + }); + await badge.waitFor({ state: "hidden", timeout: 20_000 }); +} + +async function openLoginQr(page: Page): Promise { + const auth = page.locator("#auth-button"); + await auth.waitFor({ state: "visible", timeout: 30_000 }); + await page.waitForFunction( + () => !document.querySelector("#auth-button")?.disabled, + null, + { timeout: 30_000 }, + ); + await auth.click(); + + const qr = page.locator("#auth-modal-qr canvas"); + await qr.waitFor({ state: "visible", timeout: 30_000 }); + await captureStep(page, "login-qr"); + return await extractQrPayload(page, "#auth-modal-qr canvas"); +} + +async function signInWithBot(page: Page): Promise { + const { token, base, network } = requireBotEnv(); + const handshake = await openLoginQr(page); + const username = generateUsername(); + console.log(`[e2e-dotli] pairing signer-bot user ${username}`); + const result = await pair(base, token, { + handshake, + username, + network, + }); + try { + await waitForSignedIn(page, result); + await captureStep(page, "signed-in"); + } catch (error) { + await disconnect(base, token, result.sessionId); + throw error; + } + return result; +} + +async function waitForSignedIn(page: Page, result: PairResult): Promise { + try { + const existingFailure = await latestLoginFailureReason(page); + if (existingFailure !== null) { + throw new Error(`Login failed: ${existingFailure}`); + } + await Promise.race([ + page + .locator("#auth-button .user-badge") + .waitFor({ state: "visible", timeout: loginUserBadgeTimeoutMs }), + page.evaluate( + () => + new Promise((_, reject) => { + const listener = (event: Event): void => { + const state = ( + event as CustomEvent< + { tag?: string; reason?: string } | undefined + > + ).detail; + if (state?.tag !== "LoginFailed") { + return; + } + window.removeEventListener("dotli:truapi-auth-state", listener); + reject(new Error(`Login failed: ${state.reason ?? "unknown"}`)); + }; + window.addEventListener("dotli:truapi-auth-state", listener); + }), + ), + ]); + } catch (error) { + await writeAuthDebug(page, { + stage: "post-pair-user-badge", + pairResult: redactedPairResult(result), + }); + throw error; + } +} + +async function latestLoginFailureReason(page: Page): Promise { + return await page.evaluate(() => { + const states = window.__dotliE2eAuthStates ?? []; + for (let i = states.length - 1; i >= 0; i--) { + const candidate = states[i] as { + detail?: { tag?: string; reason?: string }; + }; + if (candidate.detail?.tag === "LoginFailed") { + return candidate.detail.reason ?? "unknown"; + } + } + return null; + }); +} + +function redactedPairResult(result: PairResult): PairResult { + return { + sessionId: result.sessionId, + user: { + username: result.user.username, + network: result.user.network, + address: result.user.address, + publicKeyHex: result.user.publicKeyHex, + attested: result.user.attested, + }, + }; +} + +async function writeAuthDebug( + page: Page, + extra: Record, +): Promise { + const debug = await page.evaluate(() => { + const safeStorageValue = (key: string, value: string): string => { + if ( + key === "dotli:mode" || + key === "dotli:network" || + key === "dotli:chain-backend" || + key === "dotli:content-backend" || + key === "truapi:logLevel" || + key === "dotli:truapi-debug" + ) { + return value; + } + return "[redacted]"; + }; + const readStorage = (storage: Storage): Record => { + const values: Record = {}; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key !== null && /dotli|truapi/i.test(key)) { + values[key] = safeStorageValue(key, storage.getItem(key) ?? ""); + } + } + return values; + }; + + const authButton = document.querySelector("#auth-button"); + const modal = document.querySelector("#auth-modal-backdrop"); + const modalReason = document.querySelector("#auth-modal-reason"); + return { + url: location.href, + authStates: window.__dotliE2eAuthStates ?? [], + authButtonHtml: authButton?.outerHTML ?? null, + authButtonText: authButton?.textContent ?? null, + modalClass: modal?.getAttribute("class") ?? null, + modalReasonText: modalReason?.textContent ?? null, + localStorage: readStorage(localStorage), + sessionStorage: readStorage(sessionStorage), + }; + }); + const metadataPath = resolve(outputDir, "auth-debug.json"); + writeFileSync( + metadataPath, + `${JSON.stringify({ ...debug, ...extra }, null, 2)}\n`, + ); + console.error(`[e2e-dotli] auth debug: ${metadataPath}`); +} + +function startHostModalClicker(page: Page): () => void { + let stopped = false; + void (async () => { + while (!stopped) { + await acceptVisibleHostModal(page); + await page.waitForTimeout(250).catch(() => {}); + } + })(); + return () => { + stopped = true; + }; +} + +async function drainHostModals(page: Page, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if ( + !(await page + .locator(".signing-modal-backdrop") + .isVisible() + .catch(() => false)) + ) { + return; + } + if (!(await acceptVisibleHostModal(page))) { + await page.waitForTimeout(250); + } + } +} + +async function acceptVisibleHostModal(page: Page): Promise { + const allowedLabels = new Set(["Allow", "Create", "Sign"]); + const buttons = page.locator(".signing-modal-backdrop button"); + const count = await buttons.count().catch(() => 0); + for (let index = 0; index < count; index++) { + const button = buttons.nth(index); + const visible = await button.isVisible({ timeout: 100 }).catch(() => false); + const enabled = await button.isEnabled({ timeout: 100 }).catch(() => false); + if (!visible || !enabled) { + continue; + } + const label = (await button.innerText().catch(() => "")).trim(); + if (!allowedLabels.has(label)) { + continue; + } + console.log(`[e2e-dotli] accepting host modal: ${label}`); + await button.click({ timeout: 2_000 }).catch(() => {}); + return true; + } + return false; +} + +async function findPlaygroundFrame(page: Page) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const frame = page + .frames() + .find((candidate) => + candidate.url().startsWith(`http://localhost:${playgroundPort}/`), + ); + if (frame) { + const ready = await frame + .locator('[data-testid="diagnosis-entry"]') + .isVisible({ timeout: 500 }) + .catch(() => false); + if (ready) { + return frame; + } + } + await page.waitForTimeout(250); + } + throw new Error("playground iframe did not become ready"); +} + +async function waitForPlaygroundE2EHook(page: Page): Promise { + const frame = await findPlaygroundFrame(page); + await frame.waitForFunction(() => Boolean(window.__truapiPlaygroundE2E), { + timeout: 15_000, + }); + await frame.evaluate(async () => { + const hook = window.__truapiPlaygroundE2E; + if (!hook?.waitForConnectionStatus) { + throw new Error("playground e2e host connection hook is unavailable"); + } + await hook.waitForConnectionStatus("connected", 30_000); + }); +} + +async function assertHostSignOutAndReconnect(page: Page): Promise { + console.log("[e2e-dotli] validating host sign-out"); + await signOutIfNeeded(page); + await page + .locator("#auth-button .user-badge") + .waitFor({ state: "hidden", timeout: 20_000 }); + await captureStep(page, "signed-out"); + + console.log("[e2e-dotli] validating signer reconnect"); + return await signInWithBot(page); +} + +async function runDiagnosis(page: Page): Promise<{ + summary: string; + report: string; + copyReportClicked: boolean; +}> { + for (let attempt = 1; attempt <= 2; attempt++) { + try { + return await runDiagnosisOnce(page); + } catch (error) { + if (attempt === 2 || !isFrameDetachedError(error)) { + throw error; + } + console.warn( + "[e2e-dotli] playground iframe detached during diagnosis; retrying once", + ); + await captureStep(page, `diagnosis-frame-detached-attempt-${attempt}`); + await page.waitForTimeout(1_000); + } + } + throw new Error("diagnosis retry exhausted"); +} + +async function runDiagnosisOnce(page: Page): Promise<{ + summary: string; + report: string; + copyReportClicked: boolean; +}> { + const frame = await findPlaygroundFrame(page); + await captureStep(page, "diagnosis-ready"); + await frame.locator('[data-testid="diagnosis-entry"]').click(); + await frame.locator('[data-testid="diagnosis-run"]').click(); + await captureStep(page, "diagnosis-running"); + + await waitForDiagnosisReportReady(frame); + + const summary = await frame + .locator('[data-testid="diagnosis-summary"]') + .innerText({ timeout: 5_000 }); + await drainHostModals(page, 5_000); + await captureStep(page, "diagnosis-report-ready"); + const report = + (await frame + .locator('[data-testid="diagnosis-report-markdown"]') + .textContent({ timeout: 5_000 })) ?? ""; + if (report.trim().length === 0) { + throw new Error("diagnosis report markdown is empty"); + } + + await frame.locator('[data-testid="diagnosis-copy-report"]').click(); + + 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; + while (Date.now() < deadline) { + const reportReady = await frame + .locator('[data-testid="diagnosis-copy-report"]') + .isVisible({ timeout: 1_000 }); + if (reportReady) { + return; + } + + const now = Date.now(); + if (now - lastLogAt >= 30_000) { + lastLogAt = now; + const progress = await frame.evaluate(() => { + const counts: Record = {}; + let running = "none"; + for (const row of document.querySelectorAll( + '[data-testid="diagnosis-row"]', + )) { + const status = row.dataset.status ?? "unknown"; + counts[status] = (counts[status] ?? 0) + 1; + if (status === "running") { + running = + row.querySelector(".diag__name")?.innerText ?? + "unknown"; + } + } + const parts = Object.entries(counts) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([status, count]) => `${status}=${count}`); + return `${parts.join(" ")} running=${running}`; + }); + console.log(`[e2e-dotli] diagnosis progress: ${progress}`); + } + + await sleep(5_000); + } + throw new Error("diagnosis did not finish within 20 minutes"); +} + +function isFrameDetachedError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("Frame was detached"); +} + +async function main(): Promise { + mkdirSync(outputDir, { recursive: true }); + mkdirSync(screenshotsDir, { recursive: true }); + if (!smokeOnly) { + const { base, network } = requireBotEnv(); + console.log(`[e2e-dotli] bot=${base} network=${network}`); + const probe = await health(base); + if (!probe.ok) { + throw new Error(`signer-bot unavailable: ${probe.error ?? probe.status}`); + } + } else { + console.log("[e2e-dotli] smoke mode: validating local stack and QR only"); + } + + let browser: Awaited> | undefined; + let context: BrowserContext | undefined; + let pairResult: PairResult | undefined; + let page: Page | undefined; + try { + await startLocalStack(); + + const executablePath = existsSync("/usr/bin/chromium") + ? "/usr/bin/chromium" + : undefined; + browser = await chromium.launch({ + headless, + slowMo, + executablePath, + args: ["--no-sandbox"], + }); + context = await browser.newContext({ + serviceWorkers: "block", + permissions: ["camera", "clipboard-read", "clipboard-write"], + }); + page = await context.newPage(); + page.on("pageerror", (error) => { + const message = error.stack ?? error.message; + pageErrors.push(message); + console.error(`[browser:pageerror] ${message}`); + }); + page.on("console", (message) => { + const text = message.text(); + if ( + message.type() === "error" || + /\[truapi|\[dotli|\[dot\.li|statement.store|signing/i.test(text) + ) { + const line = `[browser:${message.type()}] ${text}`; + browserLogs.push(line); + console.log(line); + } + }); + + await page.addInitScript( + ({ playgroundPort }) => { + try { + const playgroundLabel = `localhost:${playgroundPort}`; + localStorage.setItem("dotli:mode", "gateway"); + localStorage.setItem("dotli:chain-backend", "rpc-gateway"); + localStorage.setItem("dotli:content-backend", "ipfs-gateway"); + localStorage.setItem( + `dotli:permissions:${playgroundLabel}`, + JSON.stringify({ Camera: "granted" }), + ); + localStorage.setItem("desktop-banner-dismissed", "1"); + sessionStorage.removeItem("dotli:truapi-debug"); + localStorage.setItem("truapi:logLevel", "debug"); + localStorage.setItem("truapi:playground:e2e", "1"); + window.__TRUAPI_PLAYGROUND_E2E__ = true; + window.__dotliE2eAuthStates = []; + window.addEventListener("dotli:truapi-auth-state", (event: Event) => { + window.__dotliE2eAuthStates?.push({ + timestamp: Date.now(), + detail: (event as CustomEvent).detail, + }); + }); + } catch { + /* ignore */ + } + }, + { playgroundPort }, + ); + + const params = new URLSearchParams({ + chainBackend: "rpc-gateway", + e2e: String(Date.now()), + network: botNetwork, + }); + const url = `http://localhost:${hostPort}/localhost:${playgroundPort}?${params.toString()}`; + await page.goto(url, { timeout: 60_000, waitUntil: "domcontentloaded" }); + await captureStep(page, "loaded"); + await signOutIfNeeded(page); + if (smokeOnly) { + const handshake = await openLoginQr(page); + const metadataPath = resolve(outputDir, "smoke-run.json"); + writeFileSync( + metadataPath, + `${JSON.stringify( + { + mode: "smoke", + handshakePrefix: handshake.slice(0, 32), + pageErrors, + browserLogs, + timestamp: new Date().toISOString(), + }, + null, + 2, + )}\n`, + ); + console.log(`[e2e-dotli] smoke complete: ${metadataPath}`); + if (pageErrors.length > 0) { + throw new Error(`browser page errors occurred: ${pageErrors.length}`); + } + return; + } + await waitForPlaygroundE2EHook(page); + pairResult = await signInWithBot(page); + const stopClicker = startHostModalClicker(page); + try { + 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( + metadataPath, + `${JSON.stringify( + { + summary, + reportPath, + copyReportClicked, + screenshots, + user: redactedPairResult(pairResult).user, + sessionLifecycle: "host-sign-out-reconnect", + pageErrors, + browserLogs, + timestamp: new Date().toISOString(), + }, + null, + 2, + )}\n`, + ); + console.log(`[e2e-dotli] diagnosis complete: ${summary}`); + console.log(`[e2e-dotli] report: ${reportPath}`); + if (pageErrors.length > 0) { + throw new Error(`browser page errors occurred: ${pageErrors.length}`); + } + } finally { + stopClicker(); + } + } catch (error) { + if (page) { + await captureStep(page, "failure"); + } + throw error; + } finally { + if (pairResult) { + const { token, base } = requireBotEnv(); + await disconnect(base, token, pairResult.sessionId); + } + await context?.close().catch(() => {}); + await browser?.close().catch(() => {}); + for (const child of serverProcesses) { + child.kill("SIGTERM"); + } + } +} + +main().catch((error: unknown) => { + const message = + error instanceof Error ? (error.stack ?? error.message) : String(error); + mkdirSync(dirname(resolve(outputDir, "failure.log")), { recursive: true }); + writeFileSync(resolve(outputDir, "failure.log"), `${message}\n`); + console.error(`[e2e-dotli] ${message}`); + process.exit(1); +}); diff --git a/playground/tests/e2e/dotli/helpers/extract-qr-payload.ts b/playground/tests/e2e/dotli/helpers/extract-qr-payload.ts new file mode 100644 index 00000000..fb94678d --- /dev/null +++ b/playground/tests/e2e/dotli/helpers/extract-qr-payload.ts @@ -0,0 +1,44 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Page } from "@playwright/test"; +import jsQR from "jsqr"; + +export async function extractQrPayload( + page: Page, + canvasSelector: string, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const embedded = await page + .locator(canvasSelector) + .getAttribute("data-qr-payload", { timeout: 250 }) + .catch(() => null); + if (embedded?.startsWith("polkadotapp://")) { + return embedded; + } + + const px = await page.evaluate((sel) => { + const canvas = document.querySelector(sel) as HTMLCanvasElement | null; + if (!canvas || canvas.width === 0) return null; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + const img = ctx.getImageData(0, 0, canvas.width, canvas.height); + return { + data: Array.from(img.data), + width: img.width, + height: img.height, + }; + }, canvasSelector); + + if (px) { + const code = jsQR(new Uint8ClampedArray(px.data), px.width, px.height); + if (code?.data?.startsWith("polkadotapp://")) { + return code.data; + } + } + await page.waitForTimeout(1_000); + } + throw new Error("Could not decode QR payload from canvas"); +} diff --git a/playground/tests/e2e/dotli/helpers/signer-bot.ts b/playground/tests/e2e/dotli/helpers/signer-bot.ts new file mode 100644 index 00000000..43dfe910 --- /dev/null +++ b/playground/tests/e2e/dotli/helpers/signer-bot.ts @@ -0,0 +1,247 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { setTimeout as sleep } from "node:timers/promises"; + +const TRANSIENT = new Set([502, 503, 504]); + +// Per-attempt request timeout. First-time pair can include user creation and +// People-chain attestation, so give the one-shot handshake room to finish +// instead of aborting and retrying the same QR payload. +const PAIR_REQUEST_TIMEOUT_MS = Number( + process.env.SIGNER_BOT_PAIR_TIMEOUT_MS ?? "120000", +); +const HEALTH_REQUEST_TIMEOUT_MS = 5_000; + +function shellQuote(value: string): string { + if (value.length === 0) return "''"; + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function buildPairCurl(url: string, body: unknown): string { + return [ + `curl -sS -X POST ${shellQuote(url)}`, + '-H "Authorization: Bearer ${SIGNER_BOT_SVC_TOKEN}"', + "-H 'Content-Type: application/json'", + "-H 'Accept: application/json'", + `--data-raw ${shellQuote(JSON.stringify(body))}`, + ].join(" \\\n "); +} + +function redactSignerBotResponse(text: string): string { + return text.replace(/"mnemonic"\s*:\s*"[^"]+"/g, '"mnemonic":"[redacted]"'); +} + +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +interface TextResponse { + response: Response; + text: string; +} + +async function fetchTextWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + const text = await response.text(); + return { response, text }; + } finally { + clearTimeout(timer); + } +} + +async function fetchTextRetry( + url: string, + init: RequestInit, + attempts = 4, + timeoutMs = PAIR_REQUEST_TIMEOUT_MS, +): Promise { + let last: unknown = null; + for (let i = 1; i <= attempts; i++) { + try { + const result = await fetchTextWithTimeout(url, init, timeoutMs); + const { response, text } = result; + if (response.ok || !TRANSIENT.has(response.status) || i === attempts) { + return result; + } + console.warn( + `[bot] ${init.method ?? "GET"} ${url} response ${response.status} ${response.statusText} (attempt ${i}/${attempts}): ${redactSignerBotResponse(text)}`, + ); + } catch (e) { + last = e; + if (i === attempts) throw e; + console.warn( + `[bot] ${init.method ?? "GET"} ${url} threw "${(e as Error).message}" (attempt ${i}/${attempts})`, + ); + } + await sleep(1_000 * 2 ** (i - 1)); + } + throw last ?? new Error("fetchTextRetry exhausted"); +} + +/** + * Generate a per-run username for the Nova signing bot. + * + * Each test run gets its own throwaway user so network state (allowances, + * permissions, derived product accounts) doesn't leak between PRs. The + * format is `dotlitests` followed by 6 lowercase letters, a namespace of + * roughly 3·10^8 with no collisions in practice. It stays inside the bot's + * strictest username regex (^[a-z]+$) so it works for both regular + * `username` and `liteUsername` fields if we ever want one. + */ +export function generateUsername(): string { + const alphabet = "abcdefghijklmnopqrstuvwxyz"; + let suffix = ""; + for (let i = 0; i < 6; i++) { + suffix += alphabet[Math.floor(Math.random() * alphabet.length)]; + } + return `dotlitests${suffix}`; +} + +export interface PairResult { + sessionId: string; + user: { + username: string; + network: string; + address: string; + publicKeyHex: string; + attested?: boolean; + }; +} + +/** + * Pair the bot with a dot.li session via the QR-derived handshake deeplink. + * + * The Nova bot's `/api/pair` is one-shot: given a handshake, it + * (a) creates the user if `username` is new, (b) attests the account on + * People chain so it has Statement Store allowance, (c) completes the + * SSO handshake, (d) starts auto-signing future SignRequests for that + * session. No separate provisioning / poll step needed. + * + * `network` should match dot.li's default network (`paseo-next-v2` at time + * of writing). The bot's `/api/networks` endpoint lists supported IDs. + */ +export async function pair( + base: string, + svcToken: string, + args: { handshake: string; username: string; network: string }, +): Promise { + const url = `${base.replace(/\/$/, "")}/api/pair`; + console.log(`[bot] /api/pair curl:\n${buildPairCurl(url, args)}`); + const init: RequestInit = { + method: "POST", + headers: { + Authorization: `Bearer ${svcToken}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(args), + }; + let result: TextResponse; + try { + result = await fetchTextRetry(url, init); + } catch (e) { + console.error( + `[bot] /api/pair response unavailable: ${(e as Error).message}`, + ); + throw e; + } + const { response: r, text } = result; + console.log( + `[bot] /api/pair raw response ${r.status} ${r.statusText}: ${redactSignerBotResponse(text) || ""}`, + ); + if (!r.ok) { + throw new Error(`pair ${r.status}: ${text}`); + } + return JSON.parse(text) as PairResult; +} + +/** + * Tear down a bot session at the end of a test worker. + * + * Best-effort. Failure to disconnect is non-fatal, the bot times + * sessions out anyway. + */ +export async function disconnect( + base: string, + svcToken: string, + sessionId: string, +): Promise { + await disconnectStrict(base, svcToken, sessionId).catch(() => {}); +} + +export async function disconnectStrict( + base: string, + svcToken: string, + sessionId: string, +): Promise { + const response = await fetchWithTimeout( + `${base.replace(/\/$/, "")}/api/disconnect`, + { + method: "POST", + headers: { + Authorization: `Bearer ${svcToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ sessionId }), + }, + PAIR_REQUEST_TIMEOUT_MS, + ); + if (!response.ok) { + throw new Error(`disconnect ${response.status}: ${await response.text()}`); + } + const body = (await response.json()) as { disconnected?: boolean }; + if (body.disconnected !== true) { + throw new Error(`disconnect did not find session ${sessionId}`); + } +} + +export interface BotHealth { + ok: boolean; + status?: string; + uptime?: number; + error?: string; +} + +/** + * Lightweight bot reachability probe. Used by globalSetup to fail-fast + * when the bot is unreachable, distinguishing "Nova is down" from + * "dot.li is broken" in CI output. No auth required. + */ +export async function health(base: string): Promise { + try { + const r = await fetchWithTimeout( + `${base.replace(/\/$/, "")}/api/health`, + { method: "GET", headers: { Accept: "application/json" } }, + HEALTH_REQUEST_TIMEOUT_MS, + ); + if (!r.ok) { + return { ok: false, error: `${r.status} ${r.statusText}` }; + } + const body = (await r.json()) as { status?: string; uptime?: number }; + return { + ok: body.status === "ok", + status: body.status, + uptime: body.uptime, + }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } +} diff --git a/playground/tests/e2e/helpers.ts b/playground/tests/e2e/helpers.ts index a5cc3801..c61550ef 100644 --- a/playground/tests/e2e/helpers.ts +++ b/playground/tests/e2e/helpers.ts @@ -4,11 +4,27 @@ import { expect, type FrameLocator, type Page } from "@playwright/test"; * Open the playground inside dotli's iframe shell and wait for it to mount. * * The dotli host parses `/localhost:` as a proxy directive and iframes - * `http://localhost:3000`. We hand back the FrameLocator scoped to that - * iframe so individual specs only need to know about playground selectors. + * `http://localhost:3000`. The `dotliProductId` query param is host-only; it + * makes local e2e use the same product id as the deployed playground examples. + * We hand back the FrameLocator scoped to that iframe so individual specs only + * need to know about playground selectors. */ export async function openPlaygroundInDotli(page: Page): Promise { - await page.goto("/localhost:3000"); + await page.addInitScript(() => { + localStorage.setItem("dotli:mode", "gateway"); + localStorage.setItem("dotli:chain-backend", "rpc"); + localStorage.setItem("dotli:content-backend", "ipfs-gateway"); + localStorage.setItem( + "dotli:permissions:localhost:3000", + JSON.stringify({ Camera: "granted" }), + ); + localStorage.setItem("desktop-banner-dismissed", "1"); + localStorage.setItem("truapi:playground:e2e", "1"); + ( + window as typeof window & { __TRUAPI_PLAYGROUND_E2E__?: boolean } + ).__TRUAPI_PLAYGROUND_E2E__ = true; + }); + await page.goto("/localhost:3000?dotliProductId=truapi-playground.dot"); // dotli renders an additional hidden iframe (host.localhost:5173?mode=direct) // alongside the proxied playground; scope to the playground src so the // FrameLocator is unique under Playwright strict mode. diff --git a/playground/yarn.lock b/playground/yarn.lock index 01d5a21a..6f4162c8 100644 --- a/playground/yarn.lock +++ b/playground/yarn.lock @@ -1920,6 +1920,11 @@ json5@^1.0.2: dependencies: minimist "^1.2.0" +jsqr@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/jsqr/-/jsqr-1.4.0.tgz#8efb8d0a7cc6863cb6d95116b9069123ce9eb2d1" + integrity sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A== + "jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.5: version "3.3.5" resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz" diff --git a/rust/crates/truapi-codegen/src/main.rs b/rust/crates/truapi-codegen/src/main.rs index 42a5aa1e..d4927a61 100644 --- a/rust/crates/truapi-codegen/src/main.rs +++ b/rust/crates/truapi-codegen/src/main.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::str::FromStr; mod platform; +mod platform_callbacks; mod rust; mod rustdoc; mod ts; @@ -72,6 +73,11 @@ struct Cli { #[arg(long)] platform_wasm_adapter_output: Option, + /// Output directory for the generated Rust WASM platform bridge. + /// Only honored when `--platform-input` is also set. + #[arg(long)] + platform_rust_output: Option, + /// Output directory for generated explorer metadata (optional). When set, /// writes `codegen/types.ts` with the DataType list consumed by the /// explorer site. @@ -145,33 +151,44 @@ fn main() -> Result<()> { .with_context(|| format!("writing Rust dispatcher to {}", path.display()))?; println!("Wrote Rust dispatcher to {}", path.display()); } - if let (Some(input), Some(output)) = (&cli.platform_input, &cli.platform_ts_output) { + if let Some(input) = &cli.platform_input { + if cli.platform_wasm_adapter_output.is_some() && cli.platform_ts_output.is_none() { + anyhow::bail!("--platform-wasm-adapter-output requires --platform-ts-output"); + } let json = std::fs::read_to_string(input) .with_context(|| format!("reading platform rustdoc JSON from {input}"))?; let krate = rustdoc::parse(&json).with_context(|| format!("parsing platform rustdoc {input}"))?; let definition = platform::extract(&krate) .with_context(|| format!("extracting platform definition from {input}"))?; - let codec_types = api - .types - .iter() - .filter(|t| !matches!(t.kind, rustdoc::TypeDefKind::Alias(_))) - .map(|t| t.name.clone()) - .collect(); - let adapter_output = cli - .platform_wasm_adapter_output - .as_deref() - .unwrap_or(output.as_str()); - ts::generate_host_callbacks(&definition, &codec_types, output, adapter_output) - .with_context(|| format!("writing host callbacks TS to {output}"))?; - println!("Generated typed HostCallbacks TS surface in {output}"); - println!("Generated WASM HostCallbacks adapter in {adapter_output}"); - } else if cli.platform_input.is_some() != cli.platform_ts_output.is_some() + if let Some(output) = &cli.platform_ts_output { + let codec_types = api + .types + .iter() + .filter(|t| !matches!(t.kind, rustdoc::TypeDefKind::Alias(_))) + .map(|t| t.name.clone()) + .collect(); + let adapter_output = cli + .platform_wasm_adapter_output + .as_deref() + .unwrap_or(output.as_str()); + ts::generate_host_callbacks(&definition, &codec_types, output, adapter_output) + .with_context(|| format!("writing host callbacks TS to {output}"))?; + println!("Generated typed HostCallbacks TS surface in {output}"); + println!("Generated WASM HostCallbacks adapter in {adapter_output}"); + } + if let Some(output) = &cli.platform_rust_output { + rust::generate_wasm_bridge_file(&definition, &api, output) + .with_context(|| format!("writing Rust WASM bridge to {}", output.display()))?; + println!("Generated Rust WASM bridge in {}", output.display()); + } + } else if cli.platform_ts_output.is_some() || cli.platform_wasm_adapter_output.is_some() + || cli.platform_rust_output.is_some() { anyhow::bail!( - "--platform-input and --platform-ts-output must be provided together; \ - --platform-wasm-adapter-output additionally requires both" + "--platform-input is required for platform output; \ + --platform-wasm-adapter-output additionally requires --platform-ts-output" ); } if let Some(path) = &cli.explorer_output { diff --git a/rust/crates/truapi-codegen/src/platform_callbacks.rs b/rust/crates/truapi-codegen/src/platform_callbacks.rs new file mode 100644 index 00000000..98a33f65 --- /dev/null +++ b/rust/crates/truapi-codegen/src/platform_callbacks.rs @@ -0,0 +1,181 @@ +//! Shared callback naming and selection rules for generated platform bridges. + +use std::collections::BTreeSet; + +use crate::platform::{PlatformDefinition, PlatformInner, PlatformMethod, PlatformTrait}; +use crate::rustdoc::TypeRef; + +pub(crate) fn composed_traits(definition: &PlatformDefinition) -> Vec<&PlatformTrait> { + let composed: BTreeSet = match &definition.super_trait { + Some(s) => s.composes.iter().cloned().collect(), + None => definition.traits.iter().map(|t| t.name.clone()).collect(), + }; + definition + .traits + .iter() + .filter(|t| composed.contains(&t.name)) + .collect() +} + +pub(crate) fn raw_callback_name(method: &PlatformMethod) -> String { + to_camel_case(&method.name) +} + +pub(crate) fn platform_trait_names(definition: &PlatformDefinition) -> BTreeSet { + definition.traits.iter().map(|t| t.name.clone()).collect() +} + +pub(crate) fn trait_object_return_name<'a>( + method: &'a PlatformMethod, + platform_trait_names: &BTreeSet, +) -> Option<&'a str> { + match &method.return_shape.inner { + PlatformInner::TraitObject(name) => Some(name.as_str()), + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + named_platform_trait(ok, platform_trait_names) + } + PlatformInner::Unit | PlatformInner::Stream(_) => None, + } +} + +pub(crate) fn raw_callback_wire_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + let raw = raw_callback_name(method); + if trait_object_return_name(method, platform_trait_names).is_some() { + return format!( + "{}{}", + callback_namespace(&trait_def.name), + upper_first(&raw) + ); + } + raw +} + +pub(crate) fn raw_callback_field_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + snake_case(&raw_callback_wire_name( + trait_def, + method, + platform_trait_names, + )) +} + +pub(crate) fn raw_callback_type_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + upper_first(&raw_callback_wire_name( + trait_def, + method, + platform_trait_names, + )) +} + +pub(crate) fn raw_callback_adapter_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + format!( + "{}Adapter", + raw_callback_wire_name(trait_def, method, platform_trait_names) + ) +} + +pub(crate) fn callback_namespace(trait_name: &str) -> String { + let stem = ["Provider", "Presenter", "Host"] + .into_iter() + .find_map(|suffix| trait_name.strip_suffix(suffix)) + .unwrap_or(trait_name); + lower_pascal_case(stem) +} + +fn named_platform_trait<'a>( + ty: &'a TypeRef, + platform_trait_names: &BTreeSet, +) -> Option<&'a str> { + let TypeRef::Named { name, args } = ty else { + return None; + }; + if args.is_empty() && platform_trait_names.contains(name) { + return Some(name.as_str()); + } + None +} + +/// Unwrap a `Result` stream item to its `T`; other item types pass +/// through. Streams carry `Result`s on the Rust side but the JS raw bridge +/// already unwraps them before handing each item to the WASM callback sink. +pub(crate) fn stream_item(item: &TypeRef) -> &TypeRef { + if let TypeRef::Named { name, args } = item + && name == "Result" + && let Some(ok) = args.first() + { + return ok; + } + item +} + +pub(crate) fn to_camel_case(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut upper_next = false; + for (idx, ch) in name.chars().enumerate() { + if ch == '_' { + upper_next = idx != 0; + continue; + } + if upper_next { + out.extend(ch.to_uppercase()); + upper_next = false; + } else { + out.push(ch); + } + } + out +} + +fn lower_pascal_case(name: &str) -> String { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + format!( + "{}{}", + first.to_ascii_lowercase(), + chars.collect::() + ) +} + +fn upper_first(name: &str) -> String { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + format!( + "{}{}", + first.to_ascii_uppercase(), + chars.collect::() + ) +} + +pub(crate) fn snake_case(name: &str) -> String { + let mut out = String::with_capacity(name.len() + 4); + for (idx, ch) in name.chars().enumerate() { + if ch.is_ascii_uppercase() { + if idx != 0 { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + } else { + out.push(ch); + } + } + out +} diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index 1ca6f77c..c7878829 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -11,12 +11,15 @@ use anyhow::Result; use convert_case::{Case, Casing}; +use crate::platform::PlatformDefinition; use crate::rustdoc::*; mod dispatcher; +mod wasm_bridge; mod wire_table; pub use dispatcher::generate_dispatcher; +pub use wasm_bridge::generate_wasm_bridge; pub use wire_table::generate_wire_table; /// Generates the Rust wire dispatcher and wire-table sources into `output_dir`. @@ -29,6 +32,20 @@ pub fn generate(api: &ApiDefinition, output_dir: &Path) -> Result<()> { Ok(()) } +/// Generates the Rust wasm-bindgen platform bridge source into `output_dir`. +pub fn generate_wasm_bridge_file( + definition: &PlatformDefinition, + api: &ApiDefinition, + output_dir: &Path, +) -> Result<()> { + fs::create_dir_all(output_dir)?; + fs::write( + output_dir.join("generated_bridge.rs"), + generate_wasm_bridge(definition, api)?, + )?; + Ok(()) +} + /// Trait -> versioned-module mapping. Trait names are PascalCase /// (`JsonRpc`, `LocalStorage`); module names are snake_case /// (`jsonrpc`, `local_storage`). The mapping is irregular enough diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs new file mode 100644 index 00000000..a0e3a954 --- /dev/null +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -0,0 +1,843 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write; + +use anyhow::{Result, bail}; +use indoc::{formatdoc, writedoc}; + +use crate::platform::{PlatformDefinition, PlatformInner, PlatformMethod, PlatformTrait}; +use crate::platform_callbacks::{ + composed_traits, platform_trait_names, raw_callback_field_name, raw_callback_name, + raw_callback_wire_name, snake_case, stream_item, trait_object_return_name, +}; +use crate::rustdoc::{ApiDefinition, TypeDef, TypeDefKind, TypeRef, VariantFields}; + +pub fn generate_wasm_bridge( + definition: &PlatformDefinition, + api: &ApiDefinition, +) -> Result { + let ctx = BridgeCtx::new(definition, api); + let traits = composed_traits(definition); + let trait_names = platform_trait_names(definition); + validate_errors(&traits, &ctx)?; + let mut out = String::new(); + writedoc!( + out, + r#" + //! Auto-generated by truapi-codegen. Do not edit. + //! + //! Mechanical wasm-bindgen callback bridge derived from + //! `truapi-platform`. Raw callback names and payload shapes match the + //! generated TypeScript host-callback adapter. + + use futures::stream::BoxStream; + use js_sys::{{Function, Uint8Array}}; + use parity_scale_codec::Encode; + use truapi::v01; + use wasm_bindgen::JsValue; + + use super::{{ + WasmPlatform, call_js_function, decode_bytes, + decode_js_item, generic, get_function, 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 + /// Rust default bodies are still required here because the generated TS + /// adapter resolves optional host callbacks before constructing this + /// raw callback object. + pub(super) struct JsBridge {{ + "#, + ) + .unwrap(); + + for field in bridge_fields(&traits, &trait_names) { + writeln!(out, " pub(super) {}: Function,", field.field_name).unwrap(); + } + out.push_str("}\n\n"); + + writedoc!( + out, + r#" + impl JsBridge {{ + pub(super) fn from_js(callbacks: &JsValue) -> Result {{ + Ok(Self {{ + "#, + ) + .unwrap(); + for field in bridge_fields(&traits, &trait_names) { + writeln!( + out, + " {}: get_function(callbacks, \"{}\")?,", + field.field_name, field.raw_name + ) + .unwrap(); + } + out.push_str(" })\n }\n}\n\n"); + + let mut parse_fns = BTreeMap::new(); + for trait_def in traits { + if requires_manual_trait_impl(trait_def, &trait_names) { + continue; + } + out.push_str(&emit_trait_impl(trait_def, &ctx, &mut parse_fns)?); + out.push('\n'); + } + for (idx, (_name, body)) in parse_fns.into_iter().enumerate() { + if idx != 0 { + out.push('\n'); + } + out.push_str(body.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +struct BridgeCtx<'a> { + api_types: BTreeMap<&'a str, &'a TypeDef>, + codec_types: BTreeSet<&'a str>, + local_types: BTreeSet<&'a str>, + local_codec_types: BTreeSet<&'a str>, +} + +impl<'a> BridgeCtx<'a> { + fn new(definition: &'a PlatformDefinition, api: &'a ApiDefinition) -> Self { + let api_types = api + .types + .iter() + .map(|ty| (ty.name.as_str(), ty)) + .collect::>(); + let codec_types = api + .types + .iter() + .filter(|ty| !matches!(ty.kind, TypeDefKind::Alias(_))) + .map(|ty| ty.name.as_str()) + .collect::>(); + let local_types = definition + .types + .iter() + .map(|ty| ty.name.as_str()) + .collect::>(); + let local_codec_types = collect_local_bridge_payload_types(definition); + Self { + api_types, + codec_types, + local_types, + local_codec_types, + } + } + + fn is_api_codec(&self, ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Named { name, .. } if self.codec_types.contains(name.as_str())) + } + + fn is_local_codec(&self, ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Named { name, .. } if self.local_codec_types.contains(name.as_str())) + } + + fn alias_primitive(&self, ty: &TypeRef) -> Option<&'a str> { + let TypeRef::Named { name, .. } = ty else { + return None; + }; + let mut seen = BTreeSet::new(); + let mut current = name.as_str(); + loop { + if !seen.insert(current) { + return None; + } + let TypeDefKind::Alias(target) = &self.api_types.get(current)?.kind else { + return None; + }; + match target { + TypeRef::Primitive(primitive) => return Some(primitive.as_str()), + TypeRef::Named { name, .. } => current = name, + _ => return None, + } + } + } +} + +struct BridgeField { + field_name: String, + raw_name: String, +} + +fn bridge_fields( + traits: &[&PlatformTrait], + platform_trait_names: &BTreeSet, +) -> Vec { + let mut fields = Vec::new(); + for trait_def in traits { + for method in &trait_def.methods { + fields.push(BridgeField { + field_name: raw_callback_field_name(trait_def, method, platform_trait_names), + raw_name: raw_callback_wire_name(trait_def, method, platform_trait_names), + }); + } + } + fields +} + +fn requires_manual_trait_impl( + trait_def: &PlatformTrait, + platform_trait_names: &BTreeSet, +) -> bool { + trait_def + .methods + .iter() + .any(|method| trait_object_return_name(method, platform_trait_names).is_some()) +} + +fn emit_trait_impl( + trait_def: &PlatformTrait, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + let mut out = String::new(); + if trait_def + .methods + .iter() + .any(|method| method.return_shape.is_async) + { + out.push_str("#[truapi_platform::async_trait]\n"); + } + writeln!( + out, + "impl truapi_platform::{} for WasmPlatform {{", + trait_def.name + ) + .unwrap(); + for (idx, method) in trait_def.methods.iter().enumerate() { + if idx != 0 { + out.push('\n'); + } + out.push_str(&emit_method(method, ctx, parse_fns)?); + } + out.push_str("}\n"); + Ok(out) +} + +fn emit_method( + method: &PlatformMethod, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + match &method.return_shape.inner { + PlatformInner::Result { ok, err } => emit_result_method(method, ok, err, ctx), + PlatformInner::Stream(item) => emit_stream_method(method, item, ctx, parse_fns), + PlatformInner::Unit => emit_unit_method(method, ctx), + PlatformInner::Plain(ok) => emit_plain_method(method, ok, ctx), + PlatformInner::TraitObject(_) => bail!( + "trait-object platform method `{}` must be handled manually", + method.name + ), + } +} + +fn emit_result_method( + method: &PlatformMethod, + ok: &TypeRef, + err: &TypeRef, + ctx: &BridgeCtx<'_>, +) -> Result { + let raw = raw_callback_name(method); + let args = js_arg_vec(method, ctx)?; + let ret = format!("Result<{}, {}>", rust_type(ok, ctx)?, rust_type(err, ctx)?); + let params = rust_params(method, ctx)?; + let map_err = error_mapper(err, ctx)?; + + let body = if is_unit(ok) { + await_chain( + &bridge_call("invoke_unit", &method.name, &args, &[]), + &map_err, + ) + } else if is_bool(ok) { + await_chain( + &bridge_call("invoke_bool", &method.name, &args, &[]), + &map_err, + ) + } else if is_bytes(ok) { + await_chain( + &bridge_call("invoke_bytes_return", &method.name, &args, &[]), + &map_err, + ) + } else if is_optional_bytes(ok) { + await_chain( + &bridge_call( + "invoke_optional_bytes_return", + &method.name, + &args, + &[format!( + "{:?}", + format!("{raw} must resolve to Uint8Array, null or undefined") + )], + ), + &map_err, + ) + } else if ctx.is_api_codec(ok) || ctx.is_local_codec(ok) { + formatdoc_decode_result(method, ok, &raw, &args, &map_err, ctx)? + } else { + bail!("unsupported wasm bridge result type for `{raw}`: {ok:?}"); + }; + + Ok(format!( + "{header}\n{body}\n }}\n", + header = method_header("async ", &method.name, ¶ms, Some(&ret)), + body = indent_body(&body, 8), + )) +} + +fn emit_plain_method(method: &PlatformMethod, ok: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + if !is_unit(ok) { + bail!( + "unsupported non-async plain return for wasm bridge method `{}`", + method.name + ); + } + emit_unit_method(method, ctx) +} + +fn emit_unit_method(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + let args = js_arg_vec(method, ctx)?; + let params = rust_params(method, ctx)?; + let body = if method.return_shape.is_async { + format!( + "if let Err(reason) = {}\n .await\n{{\n web_sys::console::error_1(&JsValue::from_str(&reason));\n}}", + bridge_call("invoke_unit", &method.name, &args, &[]) + ) + } else { + let args = if args == "Vec::new()" { + "&[]".to_string() + } else { + format!("&{args}") + }; + format!( + "if let Err(reason) = {} {{\n web_sys::console::error_1(&JsValue::from_str(&reason));\n}}", + bridge_call("call_js_function", &method.name, &args, &[]) + ) + }; + Ok(format!( + "{header}\n{body}\n }}\n", + header = method_header( + if method.return_shape.is_async { + "async " + } else { + "" + }, + &method.name, + ¶ms, + None + ), + body = indent_body(&body, 8), + )) +} + +fn emit_stream_method( + method: &PlatformMethod, + item: &TypeRef, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + let raw = raw_callback_name(method); + let TypeRef::Named { name, args } = item else { + bail!("stream `{raw}` must yield Result"); + }; + if name != "Result" || args.len() != 2 { + bail!("stream `{raw}` must yield Result"); + } + let ok = stream_item(item); + let err = &args[1]; + let ret = format!( + "BoxStream<'static, Result<{}, {}>>", + rust_type(ok, ctx)?, + rust_type(err, ctx)? + ); + let params = rust_params(method, ctx)?; + let payload = subscription_payload(method, ctx)?; + let parser = stream_parser(ok, ctx, parse_fns)?; + let body = if payload == "None" { + format!( + "invoke_js_subscription(&self.bridge.{}, None, {parser})", + method.name + ) + } else { + bridge_call( + "invoke_js_subscription", + &method.name, + &payload, + std::slice::from_ref(&parser), + ) + }; + Ok(format!( + "{header}\n{body}\n }}\n", + header = method_header("", &method.name, ¶ms, Some(&ret)), + body = indent_body(&body, 8), + )) +} + +fn formatdoc_decode_result( + method: &PlatformMethod, + ok: &TypeRef, + raw: &str, + args: &str, + map_err: &str, + ctx: &BridgeCtx<'_>, +) -> Result { + let ty = rust_type(ok, ctx)?; + let call = bridge_call("invoke_bytes_return", &method.name, args, &[]); + let await_bytes = await_chain(&call, &format!("{map_err}?")); + Ok(format!( + "let bytes = {await_bytes};\ndecode_bytes::<{ty}>(\n bytes,\n {:?},\n)\n{map_err}", + format!("{raw} response did not decode"), + )) +} + +fn rust_params(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + method + .params + .iter() + .map(|param| { + Ok(format!( + "{}: {}", + param.name, + rust_type(¶m.type_ref, ctx)? + )) + }) + .collect::>>() + .map(|params| params.join(", ")) +} + +fn method_header(keyword: &str, name: &str, params: &str, ret: Option<&str>) -> String { + let ret = ret.map(|ret| format!(" -> {ret}")).unwrap_or_default(); + if params.is_empty() { + return format!(" {keyword}fn {name}(&self){ret} {{"); + } + + let one_line = format!(" {keyword}fn {name}(&self, {params}){ret} {{"); + if one_line.len() <= 100 { + return one_line; + } + + let mut out = format!(" {keyword}fn {name}(\n &self,\n"); + for param in params.split(", ") { + writeln!(out, " {param},").unwrap(); + } + write!(out, " ){ret} {{").unwrap(); + out +} + +fn bridge_call(name: &str, field: &str, second_arg: &str, extra_args: &[String]) -> String { + let mut args = vec![format!("&self.bridge.{field}"), second_arg.to_string()]; + args.extend(extra_args.iter().cloned()); + let one_line = format!("{name}({})", args.join(", ")); + if !one_line.contains('\n') && one_line.len() <= 72 { + return one_line; + } + + let mut out = format!("{name}(\n &self.bridge.{field},\n"); + out.push_str(&indent_body(second_arg, 4)); + out.push_str(",\n"); + for arg in extra_args { + writeln!(out, " {arg},").unwrap(); + } + out.push(')'); + out +} + +fn await_chain(call: &str, map_err: &str) -> String { + if call.contains('\n') { + format!("{call}\n.await\n{map_err}") + } else { + format!("{call}\n .await\n {map_err}") + } +} + +fn rust_type(ty: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + match ty { + TypeRef::Primitive(name) if name == "String" || name == "str" => Ok("String".to_string()), + TypeRef::Primitive(name) => Ok(name.clone()), + TypeRef::Named { name, args } if name == "String" && args.is_empty() => { + Ok("String".to_string()) + } + TypeRef::Named { name, args } if name == "Result" && args.len() == 2 => Ok(format!( + "Result<{}, {}>", + rust_type(&args[0], ctx)?, + rust_type(&args[1], ctx)? + )), + TypeRef::Named { name, args } if ctx.api_types.contains_key(name.as_str()) => { + if args.is_empty() { + Ok(format!("v01::{name}")) + } else { + bail!("generic API type `{name}` is not supported in wasm bridge") + } + } + TypeRef::Named { name, args } if ctx.local_types.contains(name.as_str()) => { + if args.is_empty() { + Ok(format!("truapi_platform::{name}")) + } else { + bail!("generic platform type `{name}` is not supported in wasm bridge") + } + } + TypeRef::Named { name, .. } => Ok(name.clone()), + TypeRef::Vec(inner) if is_u8(inner) => Ok("Vec".to_string()), + TypeRef::Vec(inner) => Ok(format!("Vec<{}>", rust_type(inner, ctx)?)), + TypeRef::Option(inner) => Ok(format!("Option<{}>", rust_type(inner, ctx)?)), + TypeRef::Array(inner, len) => Ok(format!("[{}; {len}]", rust_type(inner, ctx)?)), + TypeRef::Tuple(items) if items.is_empty() => Ok("()".to_string()), + TypeRef::Tuple(items) => Ok(format!( + "({})", + items + .iter() + .map(|item| rust_type(item, ctx)) + .collect::>>()? + .join(", ") + )), + TypeRef::Generic(name) => Ok(name.clone()), + TypeRef::Unit => Ok("()".to_string()), + } +} + +fn js_arg_vec(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + let args = method + .params + .iter() + .map(|param| js_arg_expr(¶m.name, ¶m.type_ref, ctx)) + .collect::>>()?; + if args.is_empty() { + Ok("Vec::new()".to_string()) + } else if args.len() == 1 { + Ok(format!("vec![{}]", args[0])) + } else { + let mut out = "vec![\n".to_string(); + for arg in args { + writeln!(out, " {arg},").unwrap(); + } + out.push(']'); + Ok(out) + } +} + +fn js_arg_expr(name: &str, ty: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + if is_string(ty) { + return Ok(format!("JsValue::from_str(&{name})")); + } + if is_bytes(ty) { + return Ok(format!("Uint8Array::from({name}.as_slice()).into()")); + } + if ctx.is_api_codec(ty) || ctx.is_local_codec(ty) { + return Ok(format!( + "Uint8Array::from({name}.encode().as_slice()).into()" + )); + } + if let Some(primitive) = ctx.alias_primitive(ty) { + return numeric_js_arg(name, primitive); + } + if let TypeRef::Primitive(primitive) = ty { + return numeric_js_arg(name, primitive); + } + bail!("unsupported wasm bridge callback parameter `{name}`: {ty:?}") +} + +fn numeric_js_arg(name: &str, primitive: &str) -> Result { + match primitive { + "u8" | "u16" | "u32" | "i8" | "i16" | "i32" => { + Ok(format!("JsValue::from_f64(f64::from({name}))")) + } + "bool" => Ok(format!("JsValue::from_bool({name})")), + other => bail!("numeric callback parameter `{name}: {other}` is not JS-number safe"), + } +} + +fn subscription_payload(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + match method.params.as_slice() { + [] => Ok("None".to_string()), + [param] if is_bytes(¶m.type_ref) => Ok(format!("Some({})", param.name)), + [param] if ctx.is_api_codec(¶m.type_ref) || ctx.is_local_codec(¶m.type_ref) => { + Ok(format!("Some({}.encode())", param.name)) + } + [param] => bail!( + "unsupported subscription payload for `{}`: {:?}", + method.name, + param.type_ref + ), + _ => bail!( + "subscription `{}` has more than one payload parameter", + method.name + ), + } +} + +fn stream_parser( + ok: &TypeRef, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + if is_optional_bytes(ok) { + return Ok("parse_optional_bytes_item".to_string()); + } + if ctx.is_api_codec(ok) || ctx.is_local_codec(ok) { + let ty = rust_type(ok, ctx)?; + let label = named_type_name(ok)?; + let fn_name = format!("parse_{}_item", snake_case(label)); + parse_fns.entry(fn_name.clone()).or_insert_with(|| { + formatdoc! { + r#" + fn {fn_name}(value: JsValue) -> Result<{ty}, String> {{ + decode_js_item::<{ty}>(value, "{label}") + }} + "#, + fn_name = fn_name, + ty = ty, + label = label, + } + }); + return Ok(fn_name); + } + bail!("unsupported stream item type: {ok:?}") +} + +fn error_mapper(err: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + let name = named_type_name(err)?; + if name == "GenericError" { + return Ok(".map_err(generic)".to_string()); + } + let err_ty = rust_type(err, ctx)?; + Ok(format!(".map_err(|reason| {err_ty}::Unknown {{ reason }})")) +} + +fn validate_errors(traits: &[&PlatformTrait], ctx: &BridgeCtx<'_>) -> Result<()> { + for trait_def in traits { + for method in &trait_def.methods { + match &method.return_shape.inner { + PlatformInner::Result { err, .. } => validate_error_type(err, ctx)?, + PlatformInner::Stream(item) => { + let TypeRef::Named { name, args } = item else { + bail!("stream `{}` must yield Result", method.name); + }; + if name == "Result" && args.len() == 2 { + validate_error_type(&args[1], ctx)?; + } + } + PlatformInner::Unit | PlatformInner::Plain(_) | PlatformInner::TraitObject(_) => {} + } + } + } + Ok(()) +} + +fn validate_error_type(err: &TypeRef, ctx: &BridgeCtx<'_>) -> Result<()> { + let name = named_type_name(err)?; + if name == "GenericError" { + return Ok(()); + } + let mut seen = BTreeSet::new(); + validate_error_name(name, ctx, &mut seen) +} + +fn validate_error_name<'a>( + name: &'a str, + ctx: &BridgeCtx<'a>, + seen: &mut BTreeSet<&'a str>, +) -> Result<()> { + if name == "GenericError" { + return Ok(()); + } + if !seen.insert(name) { + bail!("platform error type `{name}` contains a recursive alias/envelope"); + } + let Some(type_def) = resolve_alias_type(name, ctx) else { + bail!("platform error type `{name}` is not present in the API definition"); + }; + let TypeDefKind::Enum(variants) = &type_def.kind else { + bail!("platform error type `{name}` must resolve to an enum"); + }; + let has_unknown_reason = variants.iter().any(|variant| { + variant.name == "Unknown" + && matches!( + &variant.fields, + VariantFields::Named(fields) + if fields.iter().any(|field| { + field.name == "reason" && is_string(&field.type_ref) + }) + ) + }); + if !has_unknown_reason { + let versioned_payloads = variants + .iter() + .filter_map(|variant| match &variant.fields { + VariantFields::Unnamed(types) if types.len() == 1 => Some(&types[0]), + _ => None, + }) + .collect::>(); + if !versioned_payloads.is_empty() && versioned_payloads.len() == variants.len() { + for payload in versioned_payloads { + validate_error_name(named_type_name(payload)?, ctx, seen)?; + } + return Ok(()); + } + bail!("platform error type `{name}` must define `Unknown {{ reason: String }}`"); + } + Ok(()) +} + +fn resolve_alias_type<'a>(name: &'a str, ctx: &BridgeCtx<'a>) -> Option<&'a TypeDef> { + let mut seen = BTreeSet::new(); + let mut current = name; + loop { + if !seen.insert(current) { + return None; + } + let type_def = *ctx.api_types.get(current)?; + match &type_def.kind { + TypeDefKind::Alias(TypeRef::Named { name, .. }) => current = name, + _ => return Some(type_def), + } + } +} + +fn named_type_name(ty: &TypeRef) -> Result<&str> { + let TypeRef::Named { name, .. } = ty else { + bail!("expected named type, got {ty:?}"); + }; + Ok(name) +} + +fn is_unit(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Unit) || matches!(ty, TypeRef::Tuple(items) if items.is_empty()) +} + +fn is_bool(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Primitive(name) if name == "bool") +} + +fn is_string(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Primitive(name) if name == "String" || name == "str") + || matches!(ty, TypeRef::Named { name, args } if name == "String" && args.is_empty()) +} + +fn is_bytes(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Vec(inner) if is_u8(inner)) + || matches!(ty, TypeRef::Array(inner, _) if is_u8(inner)) +} + +fn is_optional_bytes(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Option(inner) if is_bytes(inner)) +} + +fn is_u8(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Primitive(name) if name == "u8") +} + +fn indent_body(body: &str, spaces: usize) -> String { + let indent = " ".repeat(spaces); + body.lines() + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!("{indent}{line}") + } + }) + .collect::>() + .join("\n") +} + +fn collect_local_bridge_payload_types(definition: &PlatformDefinition) -> BTreeSet<&str> { + let local: BTreeSet<&str> = definition.types.iter().map(|ty| ty.name.as_str()).collect(); + let mut out = BTreeSet::new(); + for trait_def in &definition.traits { + for method in &trait_def.methods { + for param in &method.params { + collect_local_from_type(¶m.type_ref, &local, &mut out); + } + match &method.return_shape.inner { + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + collect_local_from_type(ok, &local, &mut out); + } + PlatformInner::Stream(item) => { + collect_local_from_type(stream_item(item), &local, &mut out) + } + PlatformInner::Unit | PlatformInner::TraitObject(_) => {} + } + } + } + let mut changed = true; + while changed { + changed = false; + let referenced = definition + .types + .iter() + .filter(|ty| out.contains(ty.name.as_str())) + .collect::>(); + for type_def in referenced { + let before = out.len(); + collect_local_from_type_def(type_def, &local, &mut out); + changed |= out.len() != before; + } + } + out +} + +fn collect_local_from_type_def<'a>( + type_def: &'a TypeDef, + local: &BTreeSet<&'a str>, + out: &mut BTreeSet<&'a str>, +) { + match &type_def.kind { + TypeDefKind::Alias(type_ref) => collect_local_from_type(type_ref, local, out), + TypeDefKind::Struct(fields) => { + for field in fields { + collect_local_from_type(&field.type_ref, local, out); + } + } + TypeDefKind::TupleStruct(fields) => { + for field in fields { + collect_local_from_type(field, local, out); + } + } + TypeDefKind::Enum(variants) => { + for variant in variants { + match &variant.fields { + VariantFields::Unit => {} + VariantFields::Unnamed(types) => { + for ty in types { + collect_local_from_type(ty, local, out); + } + } + VariantFields::Named(fields) => { + for field in fields { + collect_local_from_type(&field.type_ref, local, out); + } + } + } + } + } + } +} + +fn collect_local_from_type<'a>( + ty: &'a TypeRef, + local: &BTreeSet<&'a str>, + out: &mut BTreeSet<&'a str>, +) { + match ty { + TypeRef::Named { name, args } => { + if local.contains(name.as_str()) { + out.insert(name); + } + for arg in args { + collect_local_from_type(arg, local, out); + } + } + TypeRef::Vec(inner) | TypeRef::Option(inner) | TypeRef::Array(inner, _) => { + collect_local_from_type(inner, local, out); + } + TypeRef::Tuple(items) => { + for item in items { + collect_local_from_type(item, local, out); + } + } + TypeRef::Primitive(_) | TypeRef::Generic(_) | TypeRef::Unit => {} + } +} diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index 4d3f1882..02b52c38 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -18,6 +18,11 @@ use indoc::{formatdoc, writedoc}; use crate::platform::{ PlatformDefinition, PlatformInner, PlatformMethod, PlatformParam, PlatformReturn, PlatformTrait, }; +use crate::platform_callbacks::{ + callback_namespace, composed_traits, platform_trait_names, raw_callback_adapter_name, + raw_callback_name, raw_callback_type_name, raw_callback_wire_name, stream_item, to_camel_case, + trait_object_return_name, +}; use crate::rustdoc::{FieldDef, TypeDef, TypeDefKind, TypeRef, VariantDef, VariantFields}; use crate::ts::ts_string_literal; @@ -34,7 +39,7 @@ pub fn generate( ) -> Result<()> { fs::create_dir_all(callbacks_output_dir)?; fs::create_dir_all(adapter_output_dir)?; - let local_codec_types = collect_local_async_payload_types(definition); + let local_codec_types = collect_local_bridge_payload_types(definition); let body = emit_host_callbacks(definition, codec_types, &local_codec_types)?; fs::write( Path::new(callbacks_output_dir).join("host-callbacks.ts"), @@ -111,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::>(); @@ -120,7 +125,11 @@ fn emit_host_callbacks( out.push('\n'); } - for type_def in &definition.types { + for type_def in definition + .types + .iter() + .filter(|ty| local_codec_types.contains(&ty.name)) + { let rendered = match &type_def.kind { TypeDefKind::Enum(_) => emit_enum_type(type_def)?, _ => emit_struct_interface(type_def)?, @@ -152,25 +161,26 @@ fn emit_host_callbacks( None, ), }; - out.push_str(&emit_super_interface("HostCallbacks", &composes, docs)); + out.push_str(&emit_host_callback_composites(&composes, docs)); Ok(out) } /// Emit the `createWasmRawCallbacks` adapter that maps the typed /// `HostCallbacks` surface onto the byte-oriented surface the WASM core -/// invokes. Named wire types cross as SCALE bytes (`.enc`/`.dec`); strings, -/// primitives, byte blobs and platform-local types (`AuthState`) pass through -/// unchanged. `ChainProvider` is delegated to the hand-written -/// `chainConnectAdapter` since its connection handle is bespoke. +/// invokes. Codec-backed wire and platform-local types cross as SCALE bytes +/// (`.enc`/`.dec`); strings, primitives and byte blobs pass through unchanged. +/// Trait-object returns are delegated to hand-written adapters whose names are +/// derived from the raw callback name. fn emit_wasm_adapter( definition: &PlatformDefinition, codec_types: &BTreeSet, local_codec_types: &BTreeSet, ) -> Result { // Only the capability traits the `Platform` super-trait composes are host - // callbacks; returned handles like `JsonRpcConnection` are not. + // callbacks; returned handle traits are adapted separately. let traits = composed_traits(definition); + let trait_names = platform_trait_names(definition); // Local types are emitted in `host-callbacks.ts` (e.g. `AuthState`); codec // types carry SCALE codecs and are imported as values for `.enc`/`.dec`. @@ -181,6 +191,8 @@ fn emit_wasm_adapter( let mut imports: BTreeSet = BTreeSet::new(); let mut extra_types: BTreeSet = BTreeSet::new(); let mut adapter_local_codec_types: BTreeSet = BTreeSet::new(); + let mut runtime_types: BTreeSet = BTreeSet::new(); + let mut support_imports: BTreeSet = BTreeSet::new(); for trait_def in &traits { for method in &trait_def.methods { for param in &method.params { @@ -192,15 +204,32 @@ fn emit_wasm_adapter( ); collect_extra_named(¶m.type_ref, codec_types, &local, &mut extra_types); } + if trait_object_return_name(method, &trait_names).is_some() { + runtime_types.insert(raw_callback_type_name(trait_def, method, &trait_names)); + support_imports.insert(raw_callback_adapter_name(trait_def, method, &trait_names)); + continue; + } match &method.return_shape.inner { PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { collect_codec_imports(ok, codec_types, &mut imports); + collect_local_codec_names( + ok, + local_codec_types, + &mut adapter_local_codec_types, + ); collect_extra_named(ok, codec_types, &local, &mut extra_types); } PlatformInner::Stream(item) => { - collect_codec_imports(stream_item(item), codec_types, &mut imports) + support_imports.insert("driveResultStream".to_string()); + collect_codec_imports(stream_item(item), codec_types, &mut imports); + collect_local_codec_names( + stream_item(item), + local_codec_types, + &mut adapter_local_codec_types, + ); } - PlatformInner::Unit | PlatformInner::TraitObject(_) => {} + PlatformInner::TraitObject(_) => {} + PlatformInner::Unit => {} } } } @@ -212,9 +241,9 @@ fn emit_wasm_adapter( // Auto-generated by truapi-codegen. Do not edit. // // Adapts the typed `HostCallbacks` surface onto the byte-oriented - // callback surface the WASM core invokes. Named wire types cross as - // SCALE bytes (`.enc`/`.dec`); strings, primitives, byte blobs and - // platform-local types pass through unchanged. + // callback surface the WASM core invokes. Codec-backed wire and + // platform-local types cross as SCALE bytes (`.enc`/`.dec`); strings, + // primitives and byte blobs pass through unchanged. "#, ) @@ -230,36 +259,45 @@ fn emit_wasm_adapter( writedoc!( out, r#" - import type {{ AuthState, HostCallbacks }} from "./host-callbacks.js"; - import type {{ ChainConnect }} from "../runtime.js"; - import {{ - chainConnectAdapter, - driveResultStream, - }} from "../adapter-support.js"; + import type {{ + RequiredHostCallbacks, + }} from "./host-callbacks.js"; "#, ) .unwrap(); - out.push_str(&emit_raw_callbacks(&traits, codec_types, local_codec_types)); + emit_import_block(&mut out, true, "../runtime.js", &runtime_types); + emit_import_block(&mut out, false, "../adapter-support.js", &support_imports); + if !runtime_types.is_empty() || !support_imports.is_empty() { + out.push('\n'); + } + out.push_str(&emit_raw_callbacks( + &traits, + codec_types, + local_codec_types, + &trait_names, + )); writedoc!( out, r#" /** Adapt typed host callbacks into the raw SCALE callback surface the * WASM core invokes. */ export function createWasmRawCallbacks( - host: Required, + callbacks: RequiredHostCallbacks, ): RawCallbacks {{ return {{ "#, ) .unwrap(); for trait_def in &traits { - if trait_def.name == "ChainProvider" { - writeln!(out, " chainConnect: chainConnectAdapter(host),").unwrap(); - continue; - } for method in &trait_def.methods { - let entry = emit_adapter_entry(method, codec_types, local_codec_types)?; + let entry = emit_adapter_entry( + trait_def, + method, + codec_types, + local_codec_types, + &trait_names, + )?; writeln!(out, " {entry}").unwrap(); } } @@ -279,16 +317,24 @@ fn emit_worker_callbacks( _local_codec_types: &BTreeSet, ) -> Result { let traits = composed_traits(definition); + let trait_names = platform_trait_names(definition); let mut callbacks = Vec::new(); let mut subscriptions = Vec::new(); + let mut trait_object_callbacks = Vec::new(); + let mut runtime_types = BTreeSet::new(); for trait_def in traits { - if trait_def.name == "ChainProvider" { - continue; - } for method in &trait_def.methods { + if trait_object_return_name(method, &trait_names).is_some() { + runtime_types.insert(raw_callback_type_name(trait_def, method, &trait_names)); + trait_object_callbacks.push((trait_def, method)); + continue; + } match &method.return_shape.inner { PlatformInner::Stream(_) => subscriptions.push(method), + PlatformInner::TraitObject(_) => { + unreachable!("trait-object callbacks are handled above") + } _ => callbacks.push(method), } } @@ -305,34 +351,40 @@ fn emit_worker_callbacks( // file owns the callback names, host-hook arity, and // subscription payload shape derived from `truapi-platform`. - import type {{ ChainConnect }} from "../runtime.js"; import type {{ RawCallbacks }} from "./host-callbacks-adapter.js"; "# ) .unwrap(); + emit_import_block(&mut out, true, "../runtime.js", &runtime_types); + if !runtime_types.is_empty() { + out.push('\n'); + } out.push_str(&const_name_array("CALLBACK_NAMES", &callbacks)); out.push_str("export type CallbackName = typeof CALLBACK_NAMES[number];\n\n"); out.push_str(&const_name_array("SUBSCRIPTION_NAMES", &subscriptions)); out.push_str("export type SubscriptionName = typeof SUBSCRIPTION_NAMES[number];\n\n"); - writedoc!( - out, - r#" - export interface WorkerCallbackBridge {{ - callbackRequest(name: CallbackName, args: readonly unknown[]): Promise; - startSubscription( - name: SubscriptionName, - payload: Uint8Array | null, - sendItem: (value: T) => void, - ): () => void; - chainConnect: ChainConnect; - }} - - "# - ) - .unwrap(); + out.push_str("export interface WorkerCallbackBridge {\n"); + out.push_str( + " callbackRequest(name: CallbackName, args: readonly unknown[]): Promise;\n", + ); + out.push_str(" startSubscription(\n"); + out.push_str(" name: SubscriptionName,\n"); + out.push_str(" payload: Uint8Array | null,\n"); + out.push_str(" sendItem: (value: T) => void,\n"); + out.push_str(" ): () => void;\n"); + for (trait_def, method) in &trait_object_callbacks { + writeln!( + out, + " {}: {};", + raw_callback_wire_name(trait_def, method, &trait_names), + raw_callback_type_name(trait_def, method, &trait_names) + ) + .unwrap(); + } + out.push_str("}\n\n"); out.push_str(&emit_worker_callback_factory( "rawCallbacks", @@ -352,7 +404,16 @@ fn emit_worker_callbacks( const callbacks: Record = {{ ...rawCallbacks(bridge), ...subscriptionRawCallbacks(bridge), - chainConnect: bridge.chainConnect, + "# + ) + .unwrap(); + for (trait_def, method) in &trait_object_callbacks { + let raw = raw_callback_wire_name(trait_def, method, &trait_names); + writeln!(out, " {raw}: bridge.{raw},").unwrap(); + } + writedoc!( + out, + r#" }}; return callbacks; }} @@ -378,18 +439,6 @@ fn emit_worker_callbacks( Ok(out) } -fn composed_traits(definition: &PlatformDefinition) -> Vec<&PlatformTrait> { - let composed: BTreeSet = match &definition.super_trait { - Some(s) => s.composes.iter().cloned().collect(), - None => definition.traits.iter().map(|t| t.name.clone()).collect(), - }; - definition - .traits - .iter() - .filter(|t| composed.contains(&t.name)) - .collect() -} - /// All names defined locally in the platform crate: capability trait names plus /// the struct/enum type names emitted into `host-callbacks.ts`. fn local_names(definition: &PlatformDefinition) -> BTreeSet { @@ -404,7 +453,7 @@ fn local_names(definition: &PlatformDefinition) -> BTreeSet { fn const_name_array(const_name: &str, methods: &[&PlatformMethod]) -> String { let entries = methods .iter() - .map(|method| format!(" \"{}\",", to_camel_case(&method.name))) + .map(|method| format!(" \"{}\",", raw_callback_name(method))) .collect::>() .join("\n"); format!("export const {const_name} = [\n{entries}\n] as const;\n") @@ -431,17 +480,23 @@ fn emit_worker_callback_factory( } fn emit_worker_callback_entry(method: &PlatformMethod) -> Result { - let raw = to_camel_case(&method.name); + let raw = raw_callback_name(method); let args = method .params .iter() .map(|p| to_camel_case(&p.name)) .collect::>() .join(", "); - let arg_array = if args.is_empty() { + let arg_exprs = method + .params + .iter() + .map(|p| to_camel_case(&p.name)) + .collect::>() + .join(", "); + let arg_array = if arg_exprs.is_empty() { "[]".to_string() } else { - format!("[{args}]") + format!("[{arg_exprs}]") }; if method.return_shape.is_async { Ok(format!( @@ -471,7 +526,7 @@ fn emit_worker_subscription_factory(methods: &[&PlatformMethod]) -> Result Result, local_codec_types: &BTreeSet, + platform_trait_names: &BTreeSet, ) -> String { let mut out = String::new(); out.push_str("export interface RawCallbacks {\n"); for trait_def in traits { - if trait_def.name == "ChainProvider" { - continue; - } for method in &trait_def.methods { out.push_str(" "); - out.push_str(&raw_member(method, codec_types, local_codec_types)); + out.push_str(&raw_member( + trait_def, + method, + codec_types, + local_codec_types, + platform_trait_names, + )); out.push('\n'); } } - out.push_str(" chainConnect: ChainConnect;\n"); out.push_str("}\n"); out } /// One `RawCallbacks` member signature for `method`. fn raw_member( + trait_def: &PlatformTrait, method: &PlatformMethod, codec_types: &BTreeSet, local_codec_types: &BTreeSet, + platform_trait_names: &BTreeSet, ) -> String { - let name = to_camel_case(&method.name); + let name = raw_callback_wire_name(trait_def, method, platform_trait_names); match &method.return_shape.inner { PlatformInner::Stream(_) => { let mut params: Vec = method @@ -593,7 +653,13 @@ fn raw_member( params.push("sendItem: (item?: Uint8Array) => void".to_string()); format!("{name}({}): (() => void) | void;", params.join(", ")) } - PlatformInner::TraitObject(_) => String::new(), + _ if trait_object_return_name(method, platform_trait_names).is_some() => { + format!( + "{}: {};", + name, + raw_callback_type_name(trait_def, method, platform_trait_names) + ) + } inner => { let params = method .params @@ -609,7 +675,7 @@ fn raw_member( .join(", "); let ok = match inner { PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { - raw_ok_ts(ok, codec_types) + raw_ok_ts(ok, codec_types, local_codec_types) } _ => "void".to_string(), }; @@ -649,14 +715,22 @@ fn raw_param_ts( } /// TS type for a `RawCallbacks` `Result` ok value under the byte boundary. -fn raw_ok_ts(ty: &TypeRef, codec_types: &BTreeSet) -> String { +fn raw_ok_ts( + ty: &TypeRef, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, +) -> String { match ty { 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(), TypeRef::Vec(inner) | TypeRef::Array(inner, _) if matches!(inner.as_ref(), TypeRef::Primitive(p) if p == "u8") => { "Uint8Array".to_string() } - TypeRef::Option(inner) => format!("{} | null | undefined", raw_ok_ts(inner, codec_types)), + TypeRef::Option(inner) => format!( + "{} | null | undefined", + raw_ok_ts(inner, codec_types, local_codec_types) + ), TypeRef::Primitive(p) => raw_primitive_ts(p), TypeRef::Unit => "void".to_string(), TypeRef::Tuple(items) if items.is_empty() => "void".to_string(), @@ -716,19 +790,6 @@ fn collect_codec_imports(ty: &TypeRef, codec_types: &BTreeSet, out: &mut } } -/// Unwrap a `Result` stream item to its `T`; other item types pass -/// through. Streams carry `Result`s on the Rust side but `driveResultStream` -/// already unwraps them, so the adapter encodes the inner item type. -fn stream_item(item: &TypeRef) -> &TypeRef { - if let TypeRef::Named { name, args } = item - && name == "Result" - && let Some(ok) = args.first() - { - return ok; - } - item -} - /// The call argument expression for one Rust param. Codec types arrive as /// `Uint8Array` and are decoded; `u64`-family integers arrive as JS numbers and /// are widened to `bigint`; everything else passes through. Arrow parameter @@ -767,25 +828,39 @@ fn param_names(method: &PlatformMethod) -> String { /// host provides a complete callback implementation, so missing capability /// behavior is expressed inside the host callback rather than by omitting it. fn emit_adapter_entry( + trait_def: &PlatformTrait, method: &PlatformMethod, codec_types: &BTreeSet, local_codec_types: &BTreeSet, + platform_trait_names: &BTreeSet, ) -> Result { - let raw = to_camel_case(&method.name); + let raw = raw_callback_wire_name(trait_def, method, platform_trait_names); + let host_method = format!("callbacks.{}.{}", callback_namespace(&trait_def.name), raw); + if trait_object_return_name(method, platform_trait_names).is_some() { + let adapter = raw_callback_adapter_name(trait_def, method, platform_trait_names); + return Ok(format!( + "{raw}: {adapter}(callbacks.{}),", + callback_namespace(&trait_def.name) + )); + } let impl_expr = match &method.return_shape.inner { PlatformInner::Stream(item) => { - adapter_stream_impl(&raw, method, item, codec_types, local_codec_types)? + adapter_stream_impl(&host_method, method, item, codec_types, local_codec_types)? } PlatformInner::Result { ok, .. } => { - adapter_unary_impl(&raw, method, ok, codec_types, local_codec_types)? + adapter_unary_impl(&host_method, method, ok, codec_types, local_codec_types)? } PlatformInner::Plain(ok) => { - adapter_unary_impl(&raw, method, ok, codec_types, local_codec_types)? + adapter_unary_impl(&host_method, method, ok, codec_types, local_codec_types)? } - PlatformInner::Unit => { - adapter_unary_impl(&raw, method, &TypeRef::Unit, codec_types, local_codec_types)? - } - PlatformInner::TraitObject(_) => bail!("unexpected trait-object return on `{raw}`"), + PlatformInner::Unit => adapter_unary_impl( + &host_method, + method, + &TypeRef::Unit, + codec_types, + local_codec_types, + )?, + PlatformInner::TraitObject(_) => unreachable!("trait-object callbacks are handled above"), }; Ok(format!("{raw}: {impl_expr},")) } @@ -793,7 +868,7 @@ fn emit_adapter_entry( /// The adapter implementation expression for a unary callback: decode codec /// params, call the typed host method, SCALE-encode a codec result. fn adapter_unary_impl( - raw: &str, + host_method: &str, method: &PlatformMethod, ok: &TypeRef, codec_types: &BTreeSet, @@ -806,9 +881,11 @@ fn adapter_unary_impl( .map(|p| adapter_arg(p, codec_types, local_codec_types)) .collect::>() .join(", "); - let call = format!("host.{raw}({args})"); + let call = format!("{host_method}({args})"); let body = match ok { - TypeRef::Named { name: ty, .. } if codec_types.contains(ty) => { + TypeRef::Named { name: ty, .. } + if codec_types.contains(ty) || local_codec_types.contains(ty) => + { format!("{ty}.enc(await {call})") } _ => format!("await {call}"), @@ -819,7 +896,7 @@ fn adapter_unary_impl( /// The adapter implementation expression for a subscription callback: drive /// the host's stream into `sendItem`, SCALE-encoding each codec item. fn adapter_stream_impl( - raw: &str, + host_method: &str, method: &PlatformMethod, item: &TypeRef, codec_types: &BTreeSet, @@ -838,7 +915,9 @@ fn adapter_stream_impl( // Tick subscription: the item carries no value, so ignore it and emit // a bare tick to the core's sink. _ if is_unit => "() => sendItem()".to_string(), - TypeRef::Named { name: ty, .. } if codec_types.contains(ty) => { + TypeRef::Named { name: ty, .. } + if codec_types.contains(ty) || local_codec_types.contains(ty) => + { format!("(item) => sendItem({ty}.enc(item))") } _ => "sendItem".to_string(), @@ -850,23 +929,29 @@ fn adapter_stream_impl( .collect(); names.push("sendItem".to_string()); let params = names.join(", "); - let call = format!("host.{raw}({args})"); + let call = format!("{host_method}({args})"); Ok(format!( "({params}) => driveResultStream({call}, {item_expr})" )) } -fn collect_local_async_payload_types(definition: &PlatformDefinition) -> BTreeSet { +fn collect_local_bridge_payload_types(definition: &PlatformDefinition) -> BTreeSet { let local: BTreeSet = definition.types.iter().map(|ty| ty.name.clone()).collect(); let mut out = BTreeSet::new(); for trait_def in &definition.traits { for method in &trait_def.methods { - if !method.return_shape.is_async { - continue; - } for param in &method.params { collect_local_from_type(¶m.type_ref, &local, &mut out); } + match &method.return_shape.inner { + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + collect_local_from_type(ok, &local, &mut out); + } + PlatformInner::Stream(item) => { + collect_local_from_type(stream_item(item), &local, &mut out) + } + PlatformInner::Unit | PlatformInner::TraitObject(_) => {} + } } } let mut changed = true; @@ -1307,20 +1392,46 @@ fn inline_object_type(fields: &[FieldDef]) -> Result { Ok(format!("{{ {body} }}")) } -fn emit_super_interface(name: &str, composes: &[String], docs: Option<&str>) -> String { +fn emit_host_callback_composites(composes: &[String], docs: Option<&str>) -> String { let jsdoc = render_jsdoc("", docs); if composes.is_empty() { - return format!("{jsdoc}export interface {name} {{}}\n"); + return format!( + "{jsdoc}export interface HostCallbacks {{}}\n\nexport interface RequiredHostCallbacks {{}}\n" + ); } - let extends = composes + let host_members = composes .iter() - .map(|name| name.to_string()) + .map(|trait_name| format!(" {}: {};", callback_namespace(trait_name), trait_name)) .collect::>() - .join(", "); - format!("{jsdoc}export interface {name} extends {extends} {{}}\n") + .join("\n"); + let required_members = composes + .iter() + .map(|trait_name| { + format!( + " {}: Required<{}>;", + callback_namespace(trait_name), + trait_name + ) + }) + .collect::>() + .join("\n"); + formatdoc! { + r#" + {jsdoc}export interface HostCallbacks {{ + {host_members} + }} + + export interface RequiredHostCallbacks {{ + {required_members} + }} + "#, + } } -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 { @@ -1344,7 +1455,11 @@ fn collect_named_types(definition: &PlatformDefinition) -> BTreeSet { } } } - for type_def in &definition.types { + 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 @@ -1463,21 +1578,3 @@ fn render_ts_doc_line(line: &str) -> String { .replace("Ok(())", "success") .replace("None", "`undefined`") } - -fn to_camel_case(name: &str) -> String { - let mut out = String::with_capacity(name.len()); - let mut upper_next = false; - for (idx, ch) in name.chars().enumerate() { - if ch == '_' { - upper_next = idx != 0; - continue; - } - if upper_next { - out.extend(ch.to_uppercase()); - upper_next = false; - } else { - out.push(ch); - } - } - out -} 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 d87dfd88..9d44e561 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -1,9 +1,9 @@ // Auto-generated by truapi-codegen. Do not edit. // // Adapts the typed `HostCallbacks` surface onto the byte-oriented -// callback surface the WASM core invokes. Named wire types cross as -// SCALE bytes (`.enc`/`.dec`); strings, primitives, byte blobs and -// platform-local types pass through unchanged. +// callback surface the WASM core invokes. Codec-backed wire and +// platform-local types cross as SCALE bytes (`.enc`/`.dec`); strings, +// primitives and byte blobs pass through unchanged. import { HostDevicePermissionRequest, @@ -20,19 +20,25 @@ import type { NotificationId, } from "@parity/truapi"; import { - BulletinAllowanceSigner, + AuthState, CoreStorageKey, UserConfirmationReview, } from "./host-callbacks.js"; -import type { AuthState, HostCallbacks } from "./host-callbacks.js"; -import type { ChainConnect } from "../runtime.js"; +import type { + RequiredHostCallbacks, +} from "./host-callbacks.js"; + +import type { + ChainConnect, +} from "../runtime.js"; import { chainConnectAdapter, driveResultStream, } from "../adapter-support.js"; export interface RawCallbacks { - authStateChanged(state: AuthState): void; + authStateChanged(state: Uint8Array): void; + chainConnect: ChainConnect; readCoreStorage(key: Uint8Array): Promise; writeCoreStorage(key: Uint8Array, value: Uint8Array): Promise; clearCoreStorage(key: Uint8Array): Promise; @@ -42,38 +48,35 @@ export interface RawCallbacks { cancelNotification(id: NotificationId): Promise; devicePermission(request: Uint8Array): Promise; remotePermission(request: Uint8Array): Promise; - submitPreimage(value: Uint8Array, bulletinAllowanceSigner: Uint8Array): Promise; lookupPreimage(key: Uint8Array, sendItem: (item?: Uint8Array) => void): (() => void) | void; read(key: string): Promise; write(key: string, value: Uint8Array): Promise; clear(key: string): Promise; subscribeTheme(sendItem: (item?: Uint8Array) => void): (() => void) | void; confirmUserAction(review: Uint8Array): Promise; - chainConnect: ChainConnect; } /** Adapt typed host callbacks into the raw SCALE callback surface the * WASM core invokes. */ export function createWasmRawCallbacks( - host: Required, + callbacks: RequiredHostCallbacks, ): RawCallbacks { return { - authStateChanged: async (state) => await host.authStateChanged(state), - chainConnect: chainConnectAdapter(host), - readCoreStorage: async (key) => await host.readCoreStorage(CoreStorageKey.dec(key)), - writeCoreStorage: async (key, value) => await host.writeCoreStorage(CoreStorageKey.dec(key), value), - clearCoreStorage: async (key) => await host.clearCoreStorage(CoreStorageKey.dec(key)), - featureSupported: async (request) => HostFeatureSupportedResponse.enc(await host.featureSupported(HostFeatureSupportedRequest.dec(request))), - navigateTo: async (url) => await host.navigateTo(url), - pushNotification: async (notification) => HostPushNotificationResponse.enc(await host.pushNotification(HostPushNotificationRequest.dec(notification))), - cancelNotification: async (id) => await host.cancelNotification(id), - devicePermission: async (request) => HostDevicePermissionResponse.enc(await host.devicePermission(HostDevicePermissionRequest.dec(request))), - remotePermission: async (request) => RemotePermissionResponse.enc(await host.remotePermission(RemotePermissionRequest.dec(request))), - submitPreimage: async (value, bulletinAllowanceSigner) => await host.submitPreimage(value, BulletinAllowanceSigner.dec(bulletinAllowanceSigner)), - lookupPreimage: (key, sendItem) => driveResultStream(host.lookupPreimage(key), sendItem), - read: async (key) => await host.read(key), - write: async (key, value) => await host.write(key, value), - clear: async (key) => await host.clear(key), - subscribeTheme: (sendItem) => driveResultStream(host.subscribeTheme(), (item) => sendItem(ThemeVariant.enc(item))), - confirmUserAction: async (review) => await host.confirmUserAction(UserConfirmationReview.dec(review)), + authStateChanged: async (state) => await callbacks.auth.authStateChanged(AuthState.dec(state)), + chainConnect: chainConnectAdapter(callbacks.chain), + readCoreStorage: async (key) => await callbacks.coreStorage.readCoreStorage(CoreStorageKey.dec(key)), + writeCoreStorage: async (key, value) => await callbacks.coreStorage.writeCoreStorage(CoreStorageKey.dec(key), value), + clearCoreStorage: async (key) => await callbacks.coreStorage.clearCoreStorage(CoreStorageKey.dec(key)), + featureSupported: async (request) => HostFeatureSupportedResponse.enc(await callbacks.features.featureSupported(HostFeatureSupportedRequest.dec(request))), + navigateTo: async (url) => await callbacks.navigation.navigateTo(url), + pushNotification: async (notification) => HostPushNotificationResponse.enc(await callbacks.notifications.pushNotification(HostPushNotificationRequest.dec(notification))), + cancelNotification: async (id) => await callbacks.notifications.cancelNotification(id), + devicePermission: async (request) => HostDevicePermissionResponse.enc(await callbacks.permissions.devicePermission(HostDevicePermissionRequest.dec(request))), + remotePermission: async (request) => RemotePermissionResponse.enc(await callbacks.permissions.remotePermission(RemotePermissionRequest.dec(request))), + 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), + clear: async (key) => await callbacks.productStorage.clear(key), + subscribeTheme: (sendItem) => driveResultStream(callbacks.theme.subscribeTheme(), (item) => sendItem(ThemeVariant.enc(item))), + confirmUserAction: async (review) => await callbacks.userConfirmation.confirmUserAction(UserConfirmationReview.dec(review)), }; } diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 43b3a7b4..dbe1c664 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -86,13 +86,6 @@ export type AuthState = */ | { tag: "LoginFailed"; value: { reason: string } }; -/** - * Host-facing signer for Bulletin preimage submission. - */ -export interface BulletinAllowanceSigner { - -} - /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. @@ -281,9 +274,11 @@ export const AccountAccessReview: S.Codec = S.lazy((): S.Co export const AccountAliasReview: S.Codec = S.lazy((): S.Codec => S.Struct({requestingProductId: S.str, targetProductId: S.str}) as S.Codec); /** - * Host-facing signer for Bulletin preimage submission. + * Auth/session lifecycle state the core projects for host UI. The core owns + * every transition and emits states in order; hosts render the current state + * and never derive auth UI from any other signal. */ -export const BulletinAllowanceSigner: S.Codec = S.lazy((): S.Codec => S.Struct({}) as S.Codec); +export const AuthState: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Disconnected: S._void, Pairing: S.Struct({deeplink: S.str}) as S.Codec<{ deeplink: string }>, Connected: SessionUiInfo, LoginFailed: S.Struct({reason: S.str}) as S.Codec<{ reason: string }>})); /** * Core-owned host-private storage slots. Products never address these slots; @@ -323,6 +318,12 @@ export const PermissionAuthorizationStatus: S.Codec = S.lazy((): S.Codec => S.Struct({size: S.u64}) as S.Codec); +/** + * Decoded session fields a host shell needs to render account UI without + * parsing the opaque session blob the core persists through `CoreStorage`. + */ +export const SessionUiInfo: S.Codec = S.lazy((): S.Codec => S.Struct({publicKey: S.Bytes(32), identityAccountId: S.Option(S.Bytes(32)), liteUsername: S.Option(S.str), fullUsername: S.Option(S.str)}) as S.Codec); + /** * Review shown before a sign-payload request is sent to the paired wallet. */ @@ -518,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. */ @@ -579,4 +576,30 @@ export interface UserConfirmation { /** * Combined platform interface. A host must provide all capability traits. */ -export interface HostCallbacks extends Navigation, Notifications, Permissions, Features, ProductStorage, CoreStorage, ChainProvider, AuthPresenter, UserConfirmation, ThemeHost, PreimageHost {} +export interface HostCallbacks { + navigation: Navigation; + notifications: Notifications; + permissions: Permissions; + features: Features; + productStorage: ProductStorage; + coreStorage: CoreStorage; + chain: ChainProvider; + auth: AuthPresenter; + userConfirmation: UserConfirmation; + theme: ThemeHost; + preimage: PreimageHost; +} + +export interface RequiredHostCallbacks { + navigation: Required; + notifications: Required; + permissions: Required; + features: Required; + productStorage: Required; + coreStorage: Required; + chain: Required; + auth: Required; + userConfirmation: Required; + theme: Required; + preimage: Required; +} diff --git a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs new file mode 100644 index 00000000..6b149047 --- /dev/null +++ b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs @@ -0,0 +1,289 @@ +//! Auto-generated by truapi-codegen. Do not edit. +//! +//! Mechanical wasm-bindgen callback bridge derived from +//! `truapi-platform`. Raw callback names and payload shapes match the +//! generated TypeScript host-callback adapter. + +use futures::stream::BoxStream; +use js_sys::{Function, Uint8Array}; +use parity_scale_codec::Encode; +use truapi::v01; +use wasm_bindgen::JsValue; + +use super::{ + WasmPlatform, call_js_function, decode_bytes, + decode_js_item, generic, get_function, 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 +/// Rust default bodies are still required here because the generated TS +/// adapter resolves optional host callbacks before constructing this +/// raw callback object. +pub(super) struct JsBridge { + pub(super) auth_state_changed: Function, + pub(super) chain_connect: Function, + pub(super) read_core_storage: Function, + pub(super) write_core_storage: Function, + pub(super) clear_core_storage: Function, + pub(super) feature_supported: Function, + pub(super) navigate_to: Function, + pub(super) push_notification: Function, + pub(super) cancel_notification: Function, + pub(super) device_permission: Function, + pub(super) remote_permission: Function, + pub(super) lookup_preimage: Function, + pub(super) read: Function, + pub(super) write: Function, + pub(super) clear: Function, + pub(super) subscribe_theme: Function, + pub(super) confirm_user_action: Function, +} + +impl JsBridge { + pub(super) fn from_js(callbacks: &JsValue) -> Result { + Ok(Self { + auth_state_changed: get_function(callbacks, "authStateChanged")?, + chain_connect: get_function(callbacks, "chainConnect")?, + read_core_storage: get_function(callbacks, "readCoreStorage")?, + write_core_storage: get_function(callbacks, "writeCoreStorage")?, + clear_core_storage: get_function(callbacks, "clearCoreStorage")?, + feature_supported: get_function(callbacks, "featureSupported")?, + navigate_to: get_function(callbacks, "navigateTo")?, + push_notification: get_function(callbacks, "pushNotification")?, + cancel_notification: get_function(callbacks, "cancelNotification")?, + device_permission: get_function(callbacks, "devicePermission")?, + remote_permission: get_function(callbacks, "remotePermission")?, + lookup_preimage: get_function(callbacks, "lookupPreimage")?, + read: get_function(callbacks, "read")?, + write: get_function(callbacks, "write")?, + clear: get_function(callbacks, "clear")?, + subscribe_theme: get_function(callbacks, "subscribeTheme")?, + confirm_user_action: get_function(callbacks, "confirmUserAction")?, + }) + } +} + +impl truapi_platform::AuthPresenter for WasmPlatform { + fn auth_state_changed(&self, state: truapi_platform::AuthState) { + if let Err(reason) = call_js_function( + &self.bridge.auth_state_changed, + &vec![Uint8Array::from(state.encode().as_slice()).into()], + ) { + web_sys::console::error_1(&JsValue::from_str(&reason)); + } + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::CoreStorage for WasmPlatform { + async fn read_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result>, v01::GenericError> { + invoke_optional_bytes_return( + &self.bridge.read_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + "readCoreStorage must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(generic) + } + + async fn write_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.write_core_storage, + vec![ + Uint8Array::from(key.encode().as_slice()).into(), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(generic) + } + + async fn clear_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.clear_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Features for WasmPlatform { + async fn feature_supported( + &self, + request: v01::HostFeatureSupportedRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.feature_supported, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "featureSupported response did not decode", + ) + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Navigation for WasmPlatform { + async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { + invoke_unit(&self.bridge.navigate_to, vec![JsValue::from_str(&url)]) + .await + .map_err(|reason| v01::HostNavigateToError::Unknown { reason }) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Notifications for WasmPlatform { + async fn push_notification( + &self, + notification: v01::HostPushNotificationRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.push_notification, + vec![Uint8Array::from(notification.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "pushNotification response did not decode", + ) + .map_err(generic) + } + + async fn cancel_notification(&self, id: v01::NotificationId) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.cancel_notification, + vec![JsValue::from_f64(f64::from(id))], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Permissions for WasmPlatform { + async fn device_permission( + &self, + request: v01::HostDevicePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.device_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "devicePermission response did not decode", + ) + .map_err(generic) + } + + async fn remote_permission( + &self, + request: v01::RemotePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.remote_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "remotePermission response did not decode", + ) + .map_err(generic) + } +} + +impl truapi_platform::PreimageHost for WasmPlatform { + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + invoke_js_subscription( + &self.bridge.lookup_preimage, + Some(key), + parse_optional_bytes_item, + ) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::ProductStorage for WasmPlatform { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { + invoke_optional_bytes_return( + &self.bridge.read, + vec![JsValue::from_str(&key)], + "read must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit( + &self.bridge.write, + vec![ + JsValue::from_str(&key), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit(&self.bridge.clear, vec![JsValue::from_str(&key)]) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } +} + +impl truapi_platform::ThemeHost for WasmPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + invoke_js_subscription(&self.bridge.subscribe_theme, None, parse_theme_variant_item) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::UserConfirmation for WasmPlatform { + async fn confirm_user_action( + &self, + review: truapi_platform::UserConfirmationReview, + ) -> Result { + invoke_bool( + &self.bridge.confirm_user_action, + vec![Uint8Array::from(review.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +fn parse_theme_variant_item(value: JsValue) -> Result { + decode_js_item::(value, "ThemeVariant") +} diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index fadb7938..8a582f93 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -5,9 +5,12 @@ // file owns the callback names, host-hook arity, and // subscription payload shape derived from `truapi-platform`. -import type { ChainConnect } from "../runtime.js"; import type { RawCallbacks } from "./host-callbacks-adapter.js"; +import type { + ChainConnect, +} from "../runtime.js"; + export const CALLBACK_NAMES = [ "authStateChanged", "readCoreStorage", @@ -19,7 +22,6 @@ export const CALLBACK_NAMES = [ "cancelNotification", "devicePermission", "remotePermission", - "submitPreimage", "read", "write", "clear", @@ -65,8 +67,6 @@ function rawCallbacks(bridge: WorkerCallbackBridge): Required, remotePermission: (request) => bridge.callbackRequest("remotePermission", [request]) as ReturnType, - submitPreimage: (value, bulletinAllowanceSigner) => - bridge.callbackRequest("submitPreimage", [value, bulletinAllowanceSigner]) as ReturnType, read: (key) => bridge.callbackRequest("read", [key]) as ReturnType, write: (key, value) => diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs index 8fc74fde..bd650d13 100644 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -10,6 +10,27 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +fn quoted_strings_in_const_array(src: &str, const_name: &str) -> Vec { + let marker = format!("export const {const_name} = ["); + let start = src + .find(&marker) + .unwrap_or_else(|| panic!("missing {const_name}")); + let rest = &src[start + marker.len()..]; + let end = rest + .find("] as const") + .unwrap_or_else(|| panic!("unterminated {const_name}")); + rest[..end] + .lines() + .filter_map(|line| { + let trimmed = line.trim().trim_end_matches(','); + trimmed + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .map(str::to_string) + }) + .collect() +} + /// Run `cargo +nightly rustdoc -p truapi --output-format json` into the /// given `target_dir` and return the path to the produced JSON file. /// Panics with a clear message if nightly is unavailable so CI cannot @@ -172,6 +193,8 @@ fn golden_host_callbacks_ts() { tempdir.path().join("host").to_str().unwrap(), "--platform-wasm-adapter-output", tempdir.path().join("wasm").to_str().unwrap(), + "--platform-rust-output", + tempdir.path().join("rust-wasm").to_str().unwrap(), ]) .output() .expect("run truapi-codegen"); @@ -222,8 +245,33 @@ fn golden_host_callbacks_ts() { ); } + let wasm_bridge_golden_path = manifest_dir.join("tests/golden/wasm_bridge.rs"); + let wasm_bridge_actual = + fs::read_to_string(tempdir.path().join("rust-wasm/generated_bridge.rs")) + .expect("read generated wasm bridge"); + let wasm_bridge_golden = fs::read_to_string(&wasm_bridge_golden_path).unwrap_or_default(); + if wasm_bridge_golden != wasm_bridge_actual { + let dump = manifest_dir.join("tests/golden/wasm_bridge.rs.actual"); + let _ = fs::write(&dump, &wasm_bridge_actual); + panic!( + "golden mismatch for wasm_bridge.rs; wrote actual to {}", + dump.display() + ); + } + assert!( !worker_actual.contains("OPTIONAL_CALLBACK_NAMES"), "worker callback generation should not expose an optional callback manifest" ); + let mut generated_names = quoted_strings_in_const_array(&worker_actual, "CALLBACK_NAMES"); + generated_names.extend(quoted_strings_in_const_array( + &worker_actual, + "SUBSCRIPTION_NAMES", + )); + for name in generated_names { + assert!( + wasm_bridge_actual.contains(&format!("get_function(callbacks, \"{name}\")?")), + "generated wasm bridge must bind worker callback `{name}`" + ); + } } diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml new file mode 100644 index 00000000..511b45bb --- /dev/null +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "truapi-host-cli" +version = "0.1.0" +edition.workspace = true +description = "Headless TrUAPI hosts: a signing-host companion and a pairing host that pair over the real People-chain statement store, for end-to-end testing without an external signer service" +license = "MIT" + +[[bin]] +name = "truapi-host" +path = "src/main.rs" + +[lints.rust] +unsafe_code = "forbid" + +[dependencies] +truapi = { path = "../truapi" } +truapi-platform = { path = "../truapi-platform" } +truapi-server = { path = "../truapi-server" } +anyhow = "1" +async-trait = "0.1" +bip39 = { version = "2", features = ["rand"] } +blake2-rfc = { version = "0.2", default-features = false } +clap = { version = "4", features = ["derive", "env"] } +frame-metadata = { version = "23", default-features = false, features = ["std", "current", "decode"] } +futures = "0.3" +futures-util = "0.3" +hex = "0.4" +parity-scale-codec = { version = "3", features = ["derive"] } +scale-info = { version = "2.11", default-features = false, features = ["decode"] } +sp-crypto-hashing = "0.1" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rustls = { version = "0.23", default-features = false, features = ["ring"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +subxt-rpcs = { version = "0.50.1", default-features = false, features = ["jsonrpsee", "native"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "io-std", "net", "time", "signal", "process"] } +tokio-stream = { version = "0.1", features = ["sync"] } +tokio-tungstenite = { version = "0.24", features = ["connect", "rustls-tls-webpki-roots"] } +# TLS is compiled in so the pairing host *can* reach real `wss://` chain nodes +# for the `Chain/*` playground methods. Live-chain routing is opt-in +# (`E2E_LIVE_CHAIN=1`); it is off by default because streaming chainHead from a +# live node over the shared product connection is not yet robust and can drop +# that connection mid-run. See src/chain.rs. +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +# Ring-VRF prover for on-chain statement-store allowance registration +# (`set_statement_store_account`). `std` already implies `prover`, but list it +# explicitly since we call the prover-gated `open`/`create`. Native only. +verifiable = { git = "https://github.com/paritytech/verifiable", rev = "f65b39df04f2f9a453d78550438b189c96785285", default-features = false, features = ["std", "prover"] } diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md new file mode 100644 index 00000000..be1f8782 --- /dev/null +++ b/rust/crates/truapi-host-cli/README.md @@ -0,0 +1,162 @@ +# truapi-host-cli + +Headless TrUAPI hosts for local end-to-end testing, built on `truapi-server`. +They replace the external signing-bot service: two CLI processes take the two +host-spec §B roles and pair over the **real People-chain statement store** (the +same node an iOS/web client uses), so tests run against a real signer with no +Novasama-operated dependency. + +Either host can be driven by a **product script** you write: a JS/TS file that +receives a global `truapi` (the `@parity/truapi` client, scoped to a product id) +and calls it like any product would. With `--script`, the CLI runs the script +and exits with its status. Without `--script`, the host stays in an interactive +shell until you quit. + +One binary, `truapi-host`: + +| Command | Role | +| --- | --- | +| `pairing-host` | Seedless host: serves product frames, emits pairing deeplinks, and can run product scripts. | +| `signing-host` | Wallet-local host: owns signer identity, can run product scripts, accepts pairing deeplinks, registers statement allowance on-chain, signs. | +| `identity-check` | Probe which derivation of a mnemonic carries a registered username. | +| `alloc-check` | Diagnose (or `--submit`) on-chain statement-store allowance: ring membership, chosen slot, and the `set_statement_store_account` extrinsic. | + +## Quick start + +```bash +make headless install # build deps + install truapi-host (once) +rust/crates/truapi-host-cli/e2e/run.sh # run js/scripts/battery.ts end-to-end +rust/crates/truapi-host-cli/e2e/run.sh path/to/my-script.ts # or a custom script +``` + +`run.sh` starts a pairing host running the product script, hands the emitted +pairing deeplink to a signing host, and exits with the script's status. The +signing host uses `--mnemonic` / `HOST_CLI_SIGNER_MNEMONIC` if set. Otherwise it +auto-selects or creates a stored account under `--base-path` (default +`$XDG_STATE_HOME/truapi-host` or `~/.local/state/truapi-host`), attests it +through the identity backend, waits for ring readiness, and rotates when the +current account exhausts Statement Store slots. Override the product with +`PRODUCT_ID=...` and the pairing frame port with `FRAME=...`. + +## Writing a product script + +A product script is top-level code (an ES module). The runner injects two +globals before running it: + +- **`truapi`** — the `@parity/truapi` client connected to the pairing host and + scoped to the host's `--product-id`. Call `truapi.account.requestLogin(...)`, + `truapi.signing.signRaw(...)`, `truapi.localStorage.write(...)`, etc. +- **`host`** — just `host.productId` and `host.productAccount(index?)`. That is + all it does: it keeps product accounts in sync with the host's `--product-id` + (hardcoding a mismatched id fails signing with `PermissionDenied`). Use + `console.log` and `throw` for everything else. + +Write it top-level and `throw` (or reject) to fail the run: + +```ts +const login = await truapi.account.requestLogin({ reason: undefined }); +if (!login.isOk() || login.value !== "Success") throw new Error("login failed"); + +const res = await truapi.signing.signRaw({ + account: host.productAccount(), + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, +}); +res.match( + (v) => console.log("signature", v.signature), + (e) => { throw new Error(JSON.stringify(e)); }, +); +``` + +`--product-id` (a `.dot` name or `localhost` identifier; default +`headless-playground.dot`) scopes product-owned APIs like `truapi.localStorage.*` +and the accounts `host.productAccount()` returns. + +Two scripts ship under `js/scripts/`: + +- `battery.ts` — the curated signer gate (login + raw/payload signing, + create-transaction, entropy). This is `run.sh`'s default. +- `diagnosis.ts` — runs the playground's own generated example sources + (`runExample`) and writes a `web.md`-shape report to + `explorer/diagnosis-reports/headless.md`, gating on the signer-critical + methods. The generated examples are baked to the `truapi-playground.dot` + product, so run it with that product id: + + ```bash + PRODUCT_ID=truapi-playground.dot E2E_LIVE_CHAIN=1 \ + rust/crates/truapi-host-cli/e2e/run.sh rust/crates/truapi-host-cli/js/scripts/diagnosis.ts + ``` + + With live routing enabled, `Chain/stop_transaction` uses host-owned operation + ids and treats already-finished provider operations as stopped. `Preimage/*` + also uses the real Bulletin Next chain and asks the signing host to claim + People-chain long-term storage before returning the product-scoped Bulletin + allowance key. It needs the playground's deps + (`cd playground && bun install`). Repeated live runs can exhaust the + signer's per-period Statement Store or Bulletin allocation slots; the + signing host now rotates auto-managed signer accounts when Statement Store + slots are exhausted. + +## Confirmations + +Both hosts take `--auto-accept`. Without it, every confirmation a web/iOS host +would show as a modal (sign requests, permission prompts) is printed on the CLI +and answered `y/n` on stdin. `run.sh` passes `--auto-accept` to both for +unattended runs. + +## Statement-store allowance + +The real statement store enforces per-account allowance. Before pairing, the +signing host grants it on-chain exactly as a real client does: it proves its +LitePeople ring membership with a bandersnatch ring-VRF and submits an unsigned +General (v5) `Resources.set_statement_store_account` extrinsic for each account +that submits statements — its own `//wallet//sso` account and the pairing host's +per-pairing device key. The port lives in `src/alloc/` (metadata-driven +signed-extension encoding, ring fetch, slot scan, ring-VRF proof, extrinsic +assembly, submit). The signing account must be an attested LitePeople member, +and may sit in an old ring, so the signing host scans back from the current ring +index (slow, one-time per pairing). Auto-managed accounts are stored in +`accounts.json` under `--base-path`; mnemonics are plaintext local test secrets +and the file is written with `0600` permissions on Unix. `alloc-check` verifies +membership and can submit a test registration. + +## Manual use (two terminals) + +```bash +make headless install + +# Terminal 1 — pairing host runs a product script and prints PAIRING_DEEPLINK: +truapi-host pairing-host --product-id myapp.dot --script js/scripts/battery.ts --auto-accept + +# Terminal 2 — hand the deeplink to a signing host (registers allowance, signs). +# The wallet mnemonic comes from --mnemonic / $HOST_CLI_SIGNER_MNEMONIC when set; +# otherwise the CLI auto-selects or creates an attested account. +truapi-host signing-host --deeplink '' --auto-accept +HOST_CLI_SIGNER_MNEMONIC="spin battle …" truapi-host signing-host --deeplink '' --auto-accept + +# Inspect on-chain statement-store allowance for a mnemonic: +truapi-host alloc-check --mnemonic "spin battle …" --lookback 100 +``` + +Both hosts take `--network` (default `paseo-next-v2`). The network preset owns +the identity backend URL, People RPC, Bulletin RPC, and genesis hashes; there is +no public `--statement-store` flag. + +## Scope / gaps + +- **Chain methods** route to real `wss://` nodes from the selected `--network` + when `E2E_LIVE_CHAIN=1`; off by default. A rustls crypto provider is + installed at startup for the TLS connections. +- **Ring-VRF product-account aliases** are implemented natively via the + `verifiable` crate (`get_account_alias`); on wasm they remain `Unavailable`. +- **`get_user_id`** resolves the signing account's username from People-chain + `Resources.Consumers`. Auto-managed signing accounts register fresh lite + usernames via the identity backend (`src/attestation.rs`); first registration + is backend-async and can take minutes (ring onboarding). `truapi-host + identity-check --mnemonic ` probes which derivation carries a username. +- `set_statement_store_account` and Bulletin long-term-storage resource + allocation are implemented over SSO on native headless hosts. +- Everything else the browser host exercises passes: signing (raw, payload, + create-transaction, and their legacy variants), statement store, entropy, + aliases, preimage, storage, permissions, notifications, theme, system, chain + (with `E2E_LIVE_CHAIN=1`), and user id, subject to live chain availability + and allowance-slot capacity. diff --git a/rust/crates/truapi-host-cli/e2e/run.sh b/rust/crates/truapi-host-cli/e2e/run.sh new file mode 100755 index 00000000..daa82fe6 --- /dev/null +++ b/rust/crates/truapi-host-cli/e2e/run.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Headless end-to-end run: a pairing host drives a product script against a +# signing host, pairing over the real People-chain statement store. +# +# make headless # build once +# e2e/run.sh # runs js/scripts/battery.ts (default) +# e2e/run.sh path/to/script.ts # runs a custom product script +# +# Env: +# PRODUCT_ID product id the pairing host serves (default headless-playground.dot) +# HOST_CLI_SIGNER_MNEMONIC optional wallet mnemonic; when unset, signing-host auto-manages one +# TRUAPI_HOST_BASE_PATH optional root for generated accounts and host state +# FRAME frame-server address (default 127.0.0.1:9955) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +BIN="$ROOT/target/debug/truapi-host" + +# Load HOST_CLI_SIGNER_MNEMONIC / TRUAPI_HOST_BASE_PATH (and any other vars) +# from a gitignored e2e/.env if present. +ENV_FILE="$(dirname "$0")/.env" +[ -f "$ENV_FILE" ] && { set -a; . "$ENV_FILE"; set +a; } + +SCRIPT="${1:-$ROOT/rust/crates/truapi-host-cli/js/scripts/battery.ts}" +PRODUCT_ID="${PRODUCT_ID:-headless-playground.dot}" +FRAME="${FRAME:-127.0.0.1:9955}" + +[ -x "$BIN" ] || { echo "missing $BIN — run: make headless" >&2; exit 2; } + +LOG="$(mktemp)" +SIGNER_PID="" +PAIR_PID="" +stop_pairing_host() { + [ -n "$PAIR_PID" ] || return 0 + pkill -TERM -P "$PAIR_PID" 2>/dev/null || true + kill -TERM "$PAIR_PID" 2>/dev/null || true + sleep 0.5 + pkill -KILL -P "$PAIR_PID" 2>/dev/null || true + kill -KILL "$PAIR_PID" 2>/dev/null || true +} +cleanup() { + [ -n "$SIGNER_PID" ] && kill "$SIGNER_PID" 2>/dev/null || true + stop_pairing_host + rm -f "$LOG" +} +trap cleanup EXIT + +# The pairing host runs the product script; the script's +# `truapi.account.requestLogin` makes the host emit a pairing deeplink, which we +# hand to a signing host. The pairing host exits with the script's status. +"$BIN" pairing-host --product-id "$PRODUCT_ID" --script "$SCRIPT" \ + --frame-listen "$FRAME" --auto-accept > >(tee "$LOG") 2>&1 & +PAIR_PID=$! + +deeplink="" +for _ in $(seq 1 600); do + deeplink="$(grep -m1 -oE 'PAIRING_DEEPLINK .+' "$LOG" | cut -d' ' -f2- || true)" + [ -n "$deeplink" ] && break + kill -0 "$PAIR_PID" 2>/dev/null || break + sleep 0.5 +done +[ -n "$deeplink" ] || { echo "pairing host did not emit a deeplink" >&2; exit 1; } + +# The signing host reads HOST_CLI_SIGNER_MNEMONIC from the env when set. +# Otherwise it auto-selects or creates an attested account under its base path. +"$BIN" signing-host --deeplink "$deeplink" --auto-accept & +SIGNER_PID=$! + +pid_running() { + local stat + stat="$(ps -p "$1" -o stat= 2>/dev/null || true)" + [ -n "$stat" ] && [ "${stat#Z}" = "$stat" ] +} + +while :; do + if ! pid_running "$PAIR_PID"; then + wait "$PAIR_PID" + exit $? + fi + if ! pid_running "$SIGNER_PID"; then + stop_pairing_host + exit 1 + fi + sleep 0.5 +done diff --git a/rust/crates/truapi-host-cli/js/diagnosis.ts b/rust/crates/truapi-host-cli/js/diagnosis.ts new file mode 100644 index 00000000..b216cadd --- /dev/null +++ b/rust/crates/truapi-host-cli/js/diagnosis.ts @@ -0,0 +1,97 @@ +// Runs the playground's own generated example sources against a headless +// pairing host, using the playground's `runExample` so these are literally the +// tests the playground diagnosis runs. Pass/fail is decided by the example +// body (it resolves on success, throws via `assert` on failure), exactly as in +// `playground/src/lib/auto-test.ts`. +import { + runExample, + type LogEntry, +} from "../../../../playground/src/lib/example-runner.ts"; +import { services } from "../../../../js/packages/truapi/src/playground/codegen/services.ts"; +import type { TrUApiClient } from "../../../../js/packages/truapi/src/index.ts"; + +// Mirrors auto-test.ts. +const UNARY_TIMEOUT_MS = 10_000; +const SIGNING_TIMEOUT_MS = 30_000; +const SSO_TIMEOUT_MS = 180_000; +const SKIPPED_SERVICES = new Set(["Chat", "Coin Payment", "Payment"]); +const SKIPPED_METHODS = new Set(["Account/create_account_proof"]); +const LONG_TIMEOUT_METHODS = new Set([ + "Account/get_account_alias", + "Resource Allocation/request", + "Signing/sign_payload", + "Signing/sign_raw", + "Signing/sign_raw_with_legacy_account", + "Signing/sign_payload_with_legacy_account", + "Signing/create_transaction", + "Signing/create_transaction_with_legacy_account", + "Preimage/submit", +]); +const METHOD_TIMEOUT_MS = new Map([ + ["Account/get_account_alias", SSO_TIMEOUT_MS], + ["Resource Allocation/request", SSO_TIMEOUT_MS], + ["Preimage/lookup_subscribe", SSO_TIMEOUT_MS], + ["Preimage/submit", SSO_TIMEOUT_MS], + ["Signing/create_transaction", SSO_TIMEOUT_MS], +]); + +export type DiagnosisStatus = "pass" | "fail" | "skipped"; +export interface DiagnosisRow { + id: string; + status: DiagnosisStatus; + output: string; +} + +async function runOne( + client: TrUApiClient, + serviceName: string, + method: { name: string; exampleSource?: string }, +): Promise { + const id = `${serviceName}/${method.name}`; + if (SKIPPED_SERVICES.has(serviceName) || SKIPPED_METHODS.has(id)) { + return { id, status: "skipped", output: "" }; + } + if (!method.exampleSource) { + return { id, status: "fail", output: "no runnable example" }; + } + const timeoutMs = + METHOD_TIMEOUT_MS.get(id) ?? + (LONG_TIMEOUT_METHODS.has(id) ? SIGNING_TIMEOUT_MS : UNARY_TIMEOUT_MS); + + const logs: LogEntry[] = []; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs / 1000}s`)), timeoutMs); + }); + let run: Awaited> | undefined; + try { + run = await Promise.race([ + runExample({ source: method.exampleSource, client, onLog: (e) => logs.push(e) }), + timeout, + ]); + await Promise.race([run.promise, timeout]); + return { id, status: "pass", output: joinLogs(logs) ?? "ok" }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const log = joinLogs(logs); + return { id, status: "fail", output: log ? `${log}\n${message}` : message }; + } finally { + if (timer !== undefined) clearTimeout(timer); + run?.cancel(); + } +} + +function joinLogs(logs: LogEntry[]): string | undefined { + return logs.length === 0 ? undefined : logs.map((l) => l.text).join("\n"); +} + +/** Run every generated example sequentially, like the playground diagnosis. */ +export async function runDiagnosis(client: TrUApiClient): Promise { + const rows: DiagnosisRow[] = []; + for (const service of services) { + for (const method of service.methods) { + rows.push(await runOne(client, service.name, method)); + } + } + return rows; +} diff --git a/rust/crates/truapi-host-cli/js/runner.ts b/rust/crates/truapi-host-cli/js/runner.ts new file mode 100644 index 00000000..4bcce35a --- /dev/null +++ b/rust/crates/truapi-host-cli/js/runner.ts @@ -0,0 +1,88 @@ +// Host-script runner: the Rust CLI spawns this to drive a headless host from a +// user-provided JavaScript/TypeScript file. +// +// The pairing host serves the product frame protocol on a WebSocket; this +// runner connects the real `@parity/truapi` client to it, injects it as the +// global `truapi` (scoped to the host's product id), and evaluates the user +// script. The script is the product: it calls `truapi.account.requestLogin()`, +// `truapi.signing.*`, `truapi.localStorage.*`, etc. A thrown error or rejected +// promise exits non-zero, so `truapi-host pairing-host --script …` is the test. +// +// Env (set by the Rust CLI): +// TRUAPI_FRAME_URL ws:// URL of the pairing host's frame server +// TRUAPI_PRODUCT_ID product id the host serves (scopes storage etc.) +// TRUAPI_SCRIPT absolute path to the user script +import { pathToFileURL } from "node:url"; +import { + createClient, + createTransport, + type ProductAccountId, + type TrUApiClient, +} from "../../../../js/packages/truapi/src/index.ts"; +import { wsProvider } from "./ws-provider.ts"; + +/// The host context injected alongside `truapi`. It only exposes what a script +/// can't get from `truapi` alone: the product id the host serves, so product +/// accounts stay in sync with `--product-id` (hardcoding a mismatched id fails +/// signing with `PermissionDenied`). Use `console.log` / `throw` for the rest. +export interface HostContext { + /** The product id this host serves (its `--product-id`). */ + productId: string; + /** A product account id for `derivationIndex` (default 0) under this product. */ + productAccount(index?: number): ProductAccountId; +} + +declare global { + // eslint-disable-next-line no-var + var truapi: TrUApiClient; + // eslint-disable-next-line no-var + var host: HostContext; +} + +const OPEN_TIMEOUT_MS = 15_000; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} must be set`); + return value; +} + +async function main() { + const frameUrl = requireEnv("TRUAPI_FRAME_URL"); + const productId = requireEnv("TRUAPI_PRODUCT_ID"); + const scriptPath = requireEnv("TRUAPI_SCRIPT"); + + const provider = wsProvider(frameUrl); + const client = createClient(createTransport(provider)); + + const context: HostContext = { + productId, + productAccount: (index = 0) => ({ dotNsIdentifier: productId, derivationIndex: index }), + }; + globalThis.truapi = client; + globalThis.host = context; + + const timer = setTimeout(() => { + console.error(`[runner] timed out connecting to ${frameUrl}`); + process.exit(2); + }, OPEN_TIMEOUT_MS); + await provider.opened; + clearTimeout(timer); + + try { + const module = await import(pathToFileURL(scriptPath).href); + if (typeof module.default === "function") { + await module.default(context); + } + } finally { + provider.dispose(); + } +} + +main().then( + () => process.exit(0), + (error) => { + console.error(`[script error] ${error instanceof Error ? error.stack : String(error)}`); + process.exit(1); + }, +); diff --git a/rust/crates/truapi-host-cli/js/scripts/battery.ts b/rust/crates/truapi-host-cli/js/scripts/battery.ts new file mode 100644 index 00000000..97d00903 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/battery.ts @@ -0,0 +1,145 @@ +/// +// Curated signer battery, as a product script for the pairing host. +// +// Run via: truapi-host pairing-host --product-id

--script js/scripts/battery.ts +// The runner injects `truapi` (the @parity/truapi client, scoped to the product) +// and `host` (`productId`, `productAccount(i)`). This is top-level product code: +// it logs in with the paired signing host, exercises the signer-backed methods +// the playground diagnosis covers, and throws on any failure so the host command +// exits non-zero. + +export {}; // module marker so top-level `await` is allowed + +const GENESIS_HASH = `0x${"11".repeat(32)}` as const; +const account = host.productAccount(); + +interface Case { + name: string; + ok: boolean; + detail: string; +} + +const results: Case[] = []; + +async function record(name: string, fn: () => Promise<{ ok: boolean; detail: string }>) { + try { + results.push({ name, ...(await fn()) }); + } catch (error) { + results.push({ name, ok: false, detail: `threw: ${String(error)}` }); + } +} + +const login = await truapi.account.requestLogin({ reason: undefined }); +results.push({ + name: "account.requestLogin", + ok: login.isOk() && login.value === "Success", + detail: login.isOk() ? String(login.value) : JSON.stringify(login.error), +}); +if (!(login.isOk() && login.value === "Success")) { + report(); + throw new Error("login did not succeed"); +} + +await record("account.getAccount", async () => { + const result = await truapi.account.getAccount({ productAccountId: account }); + return result.match( + (value) => ({ + ok: value.account.publicKey.startsWith("0x") && value.account.publicKey.length > 4, + detail: value.account.publicKey.slice(0, 18), + }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); + +await record("signing.signRaw(bytes)", async () => { + const result = await truapi.signing.signRaw({ + account, + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, + }); + return result.match( + (value) => ({ + ok: value.signature.length === 130 || value.signature.length === 132, + detail: value.signature.slice(0, 18), + }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); + +await record("signing.signRaw(message)", async () => { + const result = await truapi.signing.signRaw({ + account, + payload: { tag: "Payload", value: { payload: "hello from the headless battery" } }, + }); + return result.match( + (value) => ({ ok: value.signature.startsWith("0x"), detail: value.signature.slice(0, 18) }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); + +await record("signing.signPayload", async () => { + const result = await truapi.signing.signPayload({ + account, + payload: { + blockHash: GENESIS_HASH, + blockNumber: "0x01", + era: "0x00", + genesisHash: GENESIS_HASH, + method: "0x0400", + nonce: "0x00", + specVersion: "0x01000000", + tip: "0x00", + transactionVersion: "0x01000000", + signedExtensions: [], + version: 4, + assetId: undefined, + metadataHash: undefined, + mode: undefined, + withSignedTransaction: undefined, + }, + }); + return result.match( + (value) => ({ ok: value.signature.startsWith("0x"), detail: value.signature.slice(0, 18) }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); + +await record("signing.createTransaction", async () => { + const result = await truapi.signing.createTransaction({ + signer: account, + genesisHash: GENESIS_HASH, + callData: "0x0000", + extensions: [{ id: "CheckNonce", extra: "0x04", additionalSigned: "0x" }], + txExtVersion: 0, + }); + return result.match( + (value) => ({ + ok: value.transaction.startsWith("0x") && value.transaction.length > 4, + detail: `${value.transaction.length} chars`, + }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); + +await record("entropy.derive", async () => { + const result = await truapi.entropy.derive({ context: "0x6d792d6b6579" }); + return result.match( + (value) => ({ ok: value.entropy.startsWith("0x"), detail: value.entropy.slice(0, 18) }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); + +report(); +const failures = results.filter((r) => !r.ok); +if (failures.length > 0) { + throw new Error(`GATE FAILED: ${failures.map((r) => r.name).join(", ")}`); +} +console.log(`GATE PASSED: ${results.length} signer-critical cases`); + +function report() { + console.log("\n=== Headless host signer battery ==="); + for (const r of results) { + console.log(`${r.ok ? "PASS" : "FAIL"} ${r.name.padEnd(28)} ${r.detail}`); + } + const pass = results.filter((r) => r.ok).length; + console.log(`--------------------------------\n${pass}/${results.length} passed`); +} diff --git a/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts b/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts new file mode 100644 index 00000000..a31fa390 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts @@ -0,0 +1,82 @@ +/// +// Full playground diagnosis, as a product script for the pairing host. +// +// Run via: truapi-host pairing-host --product-id truapi-playground.dot --script js/scripts/diagnosis.ts +// The generated example sources hardcode the `truapi-playground.dot` product, so +// the pairing host must serve that product id (else signing methods fail with +// PermissionDenied). Top-level product code: logs in, runs the examples against +// the paired signing host, writes a web.md-shape report to +// explorer/diagnosis-reports/headless.md, and gates on the signer-critical +// methods (chain-node methods and deferred features are reported, not gated, +// unless a live chain node is routed in). +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runDiagnosis, type DiagnosisRow } from "../diagnosis.ts"; + +// Signer-critical, chain-node-independent methods that must pass. +const MUST_PASS = new Set([ + "Account/request_login", + "Account/connection_status_subscribe", + "Account/get_account", + "Account/get_legacy_accounts", + "Signing/sign_raw", + "Signing/sign_payload", + "Signing/sign_raw_with_legacy_account", + "Signing/sign_payload_with_legacy_account", + "Resource Allocation/request", + "Statement Store/create_proof", + "Statement Store/create_proof_authorized", + "Statement Store/submit", + "Entropy/derive", +]); + +const DEFAULT_REPORT_PATH = fileURLToPath( + new URL("../../../../../explorer/diagnosis-reports/headless.md", import.meta.url), +); +const REPORT_PATH = process.env.TRUAPI_DIAGNOSIS_REPORT_PATH || DEFAULT_REPORT_PATH; +const REPORT_TITLE = process.env.TRUAPI_DIAGNOSIS_TITLE || "Truapi Headless Pairing Host Diagnosis"; + +const login = await truapi.account.requestLogin({ reason: undefined }); +if (!login.isOk() || !["Success", "AlreadyConnected"].includes(String(login.value))) { + throw new Error(`login failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); +} + +const rows = await runDiagnosis(truapi); +mkdirSync(dirname(REPORT_PATH), { recursive: true }); +writeFileSync(REPORT_PATH, renderReport(rows)); + +console.log("\n" + renderReport(rows)); +const pass = rows.filter((r) => r.status === "pass").length; +const fail = rows.filter((r) => r.status === "fail").length; +const skip = rows.filter((r) => r.status === "skipped").length; +console.log(`\nwrote ${REPORT_PATH}`); +console.log(`${pass} passed, ${fail} failed, ${skip} skipped (of ${rows.length})`); + +const critical = rows.filter((r) => MUST_PASS.has(r.id) && r.status === "fail"); +if (critical.length > 0) { + throw new Error(`GATE FAILED: ${critical.map((r) => r.id).join(", ")}`); +} +console.log("GATE PASSED: all signer-critical methods pass"); + +// web.md-shape table so the headless run can be diffed against the browser host. +function renderReport(rows: DiagnosisRow[]): string { + const icon = (s: DiagnosisRow["status"]) => + s === "pass" ? "✅" : s === "skipped" ? "⏭️" : "❌"; + return ( + [ + `## ${REPORT_TITLE}`, + "", + "| Method | Status | Details |", + "| --- | --- | --- |", + ...rows.map( + (r) => `| \`${r.id}\` | ${icon(r.status)} | ${r.status === "fail" ? cleanDetail(r.output) : ""} |`, + ), + ].join("\n") + "\n" + ); +} + +function cleanDetail(output: string): string { + const collapsed = output.replace(/\s+/g, " ").trim(); + return collapsed.length > 300 ? collapsed.slice(0, 297) + "..." : collapsed; +} diff --git a/rust/crates/truapi-host-cli/js/scripts/preimage-smoke.ts b/rust/crates/truapi-host-cli/js/scripts/preimage-smoke.ts new file mode 100644 index 00000000..7ad5c39a --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/preimage-smoke.ts @@ -0,0 +1,31 @@ +/// +// Focused live-chain preimage smoke for debugging Bulletin submission. + +const login = await truapi.account.requestLogin({ reason: undefined }); +if (!login.isOk() || !["Success", "AlreadyConnected"].includes(String(login.value))) { + throw new Error(`requestLogin failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); +} + +const bytes = crypto.getRandomValues(new Uint8Array(4)); +const value = `0x${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}` as `0x${string}`; +console.log(`PREIMAGE_VALUE ${value}`); + +const submitted = await truapi.preimage.submit(value); +console.log(`PREIMAGE_SUBMIT ${JSON.stringify(submitted)}`); +if (!submitted.isOk()) { + throw new Error(`preimage submit failed: ${JSON.stringify(submitted.error)}`); +} + +const item = await new Promise((resolve, reject) => { + let sub: { unsubscribe: () => void } | undefined; + sub = truapi.preimage.lookupSubscribe({ request: { key: submitted.value } }).subscribe({ + next(value: unknown) { + sub?.unsubscribe(); + resolve(value); + }, + error(error: unknown) { + reject(error); + }, + }); +}); +console.log(`PREIMAGE_LOOKUP ${JSON.stringify(item)}`); diff --git a/rust/crates/truapi-host-cli/js/scripts/signing-smoke.ts b/rust/crates/truapi-host-cli/js/scripts/signing-smoke.ts new file mode 100644 index 00000000..a8a89b04 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/signing-smoke.ts @@ -0,0 +1,29 @@ +/// +export {}; + +const login = await truapi.account.requestLogin({ reason: undefined }); +if (!login.isOk() || login.value !== "AlreadyConnected") { + throw new Error(`requestLogin failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); +} + +const account = host.productAccount(); +const accountResult = await truapi.account.getAccount({ productAccountId: account }); +accountResult.match( + (value) => console.log(`ACCOUNT ${value.account.publicKey.slice(0, 18)}`), + (error) => { + throw new Error(`getAccount failed: ${JSON.stringify(error)}`); + }, +); + +const signatureResult = await truapi.signing.signRaw({ + account, + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, +}); +signatureResult.match( + (value) => console.log(`SIGNATURE ${value.signature.slice(0, 18)}`), + (error) => { + throw new Error(`signRaw failed: ${JSON.stringify(error)}`); + }, +); + +console.log("SIGNING_SMOKE_OK"); diff --git a/rust/crates/truapi-host-cli/js/ws-provider.ts b/rust/crates/truapi-host-cli/js/ws-provider.ts new file mode 100644 index 00000000..7cd7a5e4 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/ws-provider.ts @@ -0,0 +1,57 @@ +// WebSocket `WireProvider` for @parity/truapi: one binary WS message per +// SCALE protocol frame, sends buffered until the socket opens. +import type { WireProvider } from "../../../../js/packages/truapi/src/index.ts"; + +export function wsProvider(url: string): WireProvider & { opened: Promise } { + const ws = new WebSocket(url); + ws.binaryType = "arraybuffer"; + const listeners = new Set<(message: Uint8Array) => void>(); + const closeListeners = new Set<(error: Error) => void>(); + const pending: Uint8Array[] = []; + let open = false; + + let resolveOpened: () => void; + let rejectOpened: (error: Error) => void; + const opened = new Promise((resolve, reject) => { + resolveOpened = resolve; + rejectOpened = reject; + }); + + ws.addEventListener("open", () => { + open = true; + for (const frame of pending.splice(0)) ws.send(frame); + resolveOpened(); + }); + ws.addEventListener("message", (event) => { + const bytes = new Uint8Array(event.data as ArrayBuffer); + for (const listener of listeners) listener(bytes); + }); + ws.addEventListener("close", () => { + const error = new Error("websocket closed"); + for (const listener of closeListeners) listener(error); + }); + ws.addEventListener("error", () => { + const error = new Error("websocket error"); + rejectOpened(error); + for (const listener of closeListeners) listener(error); + }); + + return { + opened, + postMessage(message: Uint8Array) { + if (open) ws.send(message); + else pending.push(message); + }, + subscribe(cb: (message: Uint8Array) => void) { + listeners.add(cb); + return () => listeners.delete(cb); + }, + subscribeClose(cb: (error: Error) => void) { + closeListeners.add(cb); + return () => closeListeners.delete(cb); + }, + dispose() { + ws.close(); + }, + }; +} diff --git a/rust/crates/truapi-host-cli/src/accounts.rs b/rust/crates/truapi-host-cli/src/accounts.rs new file mode 100644 index 00000000..5907749b --- /dev/null +++ b/rust/crates/truapi-host-cli/src/accounts.rs @@ -0,0 +1,417 @@ +use std::collections::BTreeSet; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use bip39::Mnemonic; +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; +use truapi_server::host_logic::product_account::{ + derive_sr25519_hard_path, product_public_key_to_address, +}; + +use crate::alloc; +use crate::attestation; +use crate::network::NetworkConfig; + +const ACCOUNT_STORE_FILE: &str = "accounts.json"; +const DEFAULT_USERNAME_PREFIX: &str = "headless"; + +#[derive(Debug, Clone)] +pub struct ResolvedSigner { + pub entropy: Vec, + pub account_name: Option, + pub lite_username: Option, + pub auto_managed: bool, +} + +#[derive(Debug, Clone)] +pub struct ResolveSignerConfig<'a> { + pub base_path: &'a Path, + pub network: NetworkConfig, + pub mnemonic: Option, + pub account: Option, + pub lite_username_prefix: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountRecord { + pub name: String, + pub network: String, + pub mnemonic: String, + pub lite_username: String, + pub public_key_hex: String, + pub address: String, + pub created_at_unix: u64, + #[serde(default)] + pub attested: bool, + #[serde(default)] + exhausted_statement_periods: BTreeSet, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct AccountStoreData { + version: u32, + accounts: Vec, +} + +pub struct AccountStore { + path: PathBuf, + data: AccountStoreData, +} + +impl AccountStore { + pub fn load(base_path: &Path) -> Result { + let path = base_path.join(ACCOUNT_STORE_FILE); + let data = match fs::read_to_string(&path) { + Ok(text) => { + serde_json::from_str(&text).with_context(|| format!("decode {}", path.display()))? + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => AccountStoreData { + version: 1, + accounts: Vec::new(), + }, + Err(err) => return Err(err).with_context(|| format!("read {}", path.display())), + }; + Ok(Self { path, data }) + } + + pub fn save(&self) -> Result<()> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let text = serde_json::to_string_pretty(&self.data)?; + write_secret_file(&self.path, text.as_bytes()) + .with_context(|| format!("write {}", self.path.display())) + } + + pub fn get(&self, network_id: &str, name: &str) -> Option<&AccountRecord> { + self.data + .accounts + .iter() + .find(|record| record.network == network_id && record.name == name) + } + + fn upsert(&mut self, record: AccountRecord) { + if let Some(existing) = self + .data + .accounts + .iter_mut() + .find(|existing| existing.network == record.network && existing.name == record.name) + { + *existing = record; + return; + } + self.data.accounts.push(record); + } + + fn update_attested(&mut self, network_id: &str, name: &str) { + if let Some(record) = self + .data + .accounts + .iter_mut() + .find(|record| record.network == network_id && record.name == name) + { + record.attested = true; + } + } + + fn auto_candidate(&self, network_id: &str, period: u32) -> Option { + self.data + .accounts + .iter() + .find(|record| { + record.network == network_id + && record.attested + && !record.exhausted_statement_periods.contains(&period) + }) + .cloned() + } + + fn next_auto_name(&self, network_id: &str) -> String { + let mut index = 1usize; + loop { + let name = format!("auto-{index}"); + if !self + .data + .accounts + .iter() + .any(|record| record.network == network_id && record.name == name) + { + return name; + } + index += 1; + } + } + + pub fn mark_exhausted(&mut self, network_id: &str, name: &str, period: u32) -> Result<()> { + let Some(record) = self + .data + .accounts + .iter_mut() + .find(|record| record.network == network_id && record.name == name) + else { + return Ok(()); + }; + record.exhausted_statement_periods.insert(period); + self.save() + } +} + +pub async fn resolve_signer(config: ResolveSignerConfig<'_>) -> Result { + if let Some(mnemonic) = config.mnemonic { + let entropy = mnemonic_entropy(&mnemonic)?; + return Ok(ResolvedSigner { + entropy, + account_name: None, + lite_username: None, + auto_managed: false, + }); + } + + let mut store = AccountStore::load(config.base_path)?; + if let Some(name) = config.account { + let record = store + .get(config.network.id, &name) + .cloned() + .with_context(|| format!("account {name:?} not found for {}", config.network.id))?; + ensure_record_ready(&mut store, config.network, &record).await?; + return resolved_from_record(record, false); + } + + let period = current_statement_period()?; + if let Some(record) = store.auto_candidate(config.network.id, period) { + ensure_record_ready(&mut store, config.network, &record).await?; + return resolved_from_record(record, true); + } + + let record = create_auto_account( + &mut store, + config.network, + config + .lite_username_prefix + .as_deref() + .unwrap_or(DEFAULT_USERNAME_PREFIX), + ) + .await?; + resolved_from_record(record, true) +} + +pub fn mark_account_exhausted( + base_path: &Path, + network_id: &str, + name: &str, + period: u32, +) -> Result<()> { + AccountStore::load(base_path)?.mark_exhausted(network_id, name, period) +} + +pub fn current_statement_period() -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock before UNIX epoch")? + .as_secs(); + Ok(alloc::slot::current_period(now)) +} + +async fn create_auto_account( + store: &mut AccountStore, + network: NetworkConfig, + username_prefix: &str, +) -> Result { + validate_username_prefix(username_prefix)?; + let name = store.next_auto_name(network.id); + let mnemonic = Mnemonic::generate(12) + .context("generate BIP-39 mnemonic")? + .to_string(); + let identity = identity_from_mnemonic(&mnemonic)?; + + for attempt in 0..8 { + let lite_username = generated_username(username_prefix, attempt); + if !attestation::lite_username_available(network.identity_backend_base, &lite_username) + .await + .with_context(|| format!("check lite username {lite_username:?} availability"))? + { + continue; + } + + let mut record = AccountRecord { + name: name.clone(), + network: network.id.to_string(), + mnemonic: mnemonic.clone(), + lite_username, + public_key_hex: format!("0x{}", hex::encode(identity.public_key)), + address: identity.address.clone(), + created_at_unix: now_unix(), + attested: false, + exhausted_statement_periods: BTreeSet::new(), + }; + store.upsert(record.clone()); + store.save()?; + + info!( + account = %record.name, + network = %record.network, + lite_username = %record.lite_username, + address = %record.address, + "created auto signer account" + ); + + attest_record(network, &record).await?; + wait_for_ring_membership(network.people_ws, &identity.entropy).await?; + record.attested = true; + store.upsert(record.clone()); + store.save()?; + return Ok(record); + } + + bail!("could not find an available lite username for prefix {username_prefix:?}"); +} + +async fn ensure_record_ready( + store: &mut AccountStore, + network: NetworkConfig, + record: &AccountRecord, +) -> Result<()> { + let identity = identity_from_mnemonic(&record.mnemonic)?; + if !record.attested { + attest_record(network, record).await?; + store.update_attested(network.id, &record.name); + store.save()?; + } + wait_for_ring_membership(network.people_ws, &identity.entropy).await +} + +async fn attest_record(network: NetworkConfig, record: &AccountRecord) -> Result<()> { + let entropy = mnemonic_entropy(&record.mnemonic)?; + let registered = attestation::attest(&attestation::AttestConfig { + backend_base: network.identity_backend_base.to_string(), + people_ws: network.people_ws.to_string(), + entropy, + username_base: record.lite_username.clone(), + }) + .await + .with_context(|| format!("attest account {}", record.name))?; + info!( + account = %record.name, + lite_username = %record.lite_username, + registered, + "signer account attested" + ); + Ok(()) +} + +async fn wait_for_ring_membership(people_ws: &str, entropy: &[u8]) -> Result<()> { + const MAX_ATTEMPTS: usize = 90; + const SLEEP: Duration = Duration::from_secs(4); + + let bandersnatch = alloc::bandersnatch_entropy(entropy); + for attempt in 1..=MAX_ATTEMPTS { + let rpc = alloc::rpc::RpcClient::connect(people_ws).await?; + let metadata = alloc::fetch_metadata(&rpc) + .await + .map_err(anyhow::Error::msg)?; + let current = alloc::ring::read_current_ring_index(&rpc) + .await + .map_err(anyhow::Error::msg)?; + if alloc::find_including_ring(&rpc, &metadata, bandersnatch, current) + .await + .map_err(anyhow::Error::msg)? + .is_some() + { + return Ok(()); + } + if attempt < MAX_ATTEMPTS { + warn!( + attempt, + max_attempts = MAX_ATTEMPTS, + "signer account not in a LitePeople ring yet" + ); + tokio::time::sleep(SLEEP).await; + } + } + bail!("signer account did not appear in a LitePeople ring"); +} + +fn resolved_from_record(record: AccountRecord, auto_managed: bool) -> Result { + let entropy = mnemonic_entropy(&record.mnemonic)?; + Ok(ResolvedSigner { + entropy, + account_name: Some(record.name), + lite_username: Some(record.lite_username), + auto_managed, + }) +} + +struct SignerIdentity { + entropy: Vec, + public_key: [u8; 32], + address: String, +} + +fn identity_from_mnemonic(mnemonic: &str) -> Result { + let entropy = mnemonic_entropy(mnemonic)?; + let candidate = derive_sr25519_hard_path(&entropy, &["wallet", "sso"]) + .map_err(|err| anyhow::anyhow!("//wallet//sso derivation failed: {err}"))?; + let public_key = candidate.public.to_bytes(); + Ok(SignerIdentity { + entropy, + public_key, + address: product_public_key_to_address(public_key), + }) +} + +fn mnemonic_entropy(mnemonic: &str) -> Result> { + Ok(Mnemonic::parse(mnemonic.trim()) + .context("invalid BIP-39 mnemonic")? + .to_entropy()) +} + +fn validate_username_prefix(prefix: &str) -> Result<()> { + if prefix.is_empty() || !prefix.bytes().all(|byte| byte.is_ascii_lowercase()) { + bail!("--lite-username-prefix must contain lowercase ASCII letters only"); + } + Ok(()) +} + +fn generated_username(prefix: &str, attempt: usize) -> String { + let mut username = prefix.to_string(); + let mut seed = now_unix() + ^ u64::from(std::process::id()) + ^ ((attempt as u64) << 32) + ^ (prefix.len() as u64); + while username.len() < prefix.len().max(6) + 6 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + username.push((b'a' + (seed % 26) as u8) as char); + } + username +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn write_secret_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(path)?; + file.write_all(bytes)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + file.sync_all() + } + #[cfg(not(unix))] + { + fs::write(path, bytes) + } +} diff --git a/rust/crates/truapi-host-cli/src/alloc.rs b/rust/crates/truapi-host-cli/src/alloc.rs new file mode 100644 index 00000000..30b54c9c --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc.rs @@ -0,0 +1,204 @@ +//! On-chain statement-store allowance registration (`set_statement_store_account`). +//! +//! Mirrors how an iOS/web client obtains statement-store allowance from the real +//! People chain: build the `Resources.set_statement_store_account` call, prove +//! LitePeople ring membership with a bandersnatch ring-VRF, and submit the +//! resulting unsigned General (v5) extrinsic. Native only (needs the +//! `verifiable` prover and live chain reads). + +pub mod dynamic; +pub mod extension; +pub mod extrinsic; +pub mod proof; +pub mod ring; +pub mod rpc; +pub mod slot; + +use blake2_rfc::blake2b::blake2b; +use parity_scale_codec::Decode; +use serde_json::{Value, json}; + +use extension::{ChainState, Metadata}; +use ring::RingParams; +use rpc::RpcClient; +use slot::SlotSelection; + +/// Bandersnatch entropy for a bip39 entropy: `blake2b256(bip39_entropy)`. +pub fn bandersnatch_entropy(bip39_entropy: &[u8]) -> [u8; 32] { + blake2b(32, &[], bip39_entropy) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +/// Fetch and decode the runtime metadata (`state_getMetadata`). +pub async fn fetch_metadata(rpc: &RpcClient) -> Result { + let value = rpc + .call("state_getMetadata", json!([])) + .await + .map_err(|e| e.to_string())?; + let hex_str = value + .as_str() + .ok_or_else(|| "state_getMetadata returned non-string".to_string())?; + let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(hex_str)) + .map_err(|e| format!("metadata hex: {e}"))?; + // `state_getMetadata` may return either the raw `RuntimeMetadataPrefixed` + // (starts with the `meta` magic) or an OpaqueMetadata wrapper + // (`Vec` = compact(len) ‖ bytes). Strip the wrapper only when present. + const META_MAGIC: [u8; 4] = *b"meta"; + if bytes.get(..4) == Some(&META_MAGIC) { + Metadata::decode(&bytes) + } else { + let inner = + Vec::::decode(&mut &bytes[..]).map_err(|e| format!("opaque metadata: {e}"))?; + Metadata::decode(&inner) + } +} + +/// Fetch the chain state needed to fill the signed extensions. +pub async fn fetch_chain_state(rpc: &RpcClient) -> Result { + let genesis_hex = rpc + .call("chain_getBlockHash", json!([0])) + .await + .map_err(|e| e.to_string())?; + let genesis_str = genesis_hex + .as_str() + .ok_or_else(|| "chain_getBlockHash returned non-string".to_string())?; + let genesis = hex::decode(genesis_str.strip_prefix("0x").unwrap_or(genesis_str)) + .map_err(|e| format!("genesis hex: {e}"))?; + let genesis_hash: [u8; 32] = genesis + .try_into() + .map_err(|_| "genesis hash is not 32 bytes".to_string())?; + + let runtime = rpc + .call("state_getRuntimeVersion", json!([])) + .await + .map_err(|e| e.to_string())?; + let spec_version = json_u32(&runtime, "specVersion")?; + let transaction_version = json_u32(&runtime, "transactionVersion")?; + + Ok(ChainState { + spec_version, + transaction_version, + genesis_hash, + nonce: 0, + }) +} + +/// Read a u32 field from a JSON object. +fn json_u32(value: &Value, field: &str) -> Result { + value + .get(field) + .and_then(Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .ok_or_else(|| format!("missing/invalid {field}")) +} + +/// Result of a statement-store allowance registration attempt. +pub enum RegistrationOutcome { + /// The extrinsic reached a block; the target now holds slot `seq`. + Registered { + /// Block hash the extrinsic landed in. + block_hash: String, + /// Claimed slot sequence. + seq: u32, + /// Ring index the proof was built against. + ring_index: u32, + }, + /// The target already held a slot this period; nothing submitted. + AlreadyAllocated { + /// Existing slot sequence. + seq: u32, + }, +} + +/// Find the newest ring (scanning up to `lookback` back from the current index) +/// that includes our member key. Reads the ring exponent once and stops at the +/// first match. +pub async fn find_including_ring( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + lookback: u32, +) -> Result, String> { + let member = proof::member_key(entropy); + let exponent = ring::read_ring_exponent(rpc, metadata).await?; + let current = ring::read_current_ring_index(rpc).await?; + let oldest = current.saturating_sub(lookback); + for ring_index in (oldest..=current).rev() { + let members = ring::read_ring_members_at(rpc, ring_index).await?; + if members.contains(&member) { + return Ok(Some(RingParams { + members, + exponent, + ring_index, + })); + } + } + Ok(None) +} + +/// Register statement-store allowance for `target`, proving membership in the +/// already-located `ring`, at UTC-day `period`. +pub async fn register_statement_account( + rpc: &RpcClient, + metadata: &Metadata, + chain_state: &ChainState, + entropy: [u8; 32], + target: &[u8; 32], + period: u32, + ring: &RingParams, +) -> Result { + let mut skipped_duplicate_slots = Vec::new(); + loop { + let seq = match slot::scan_slot_excluding( + rpc, + metadata, + entropy, + period, + target, + &skipped_duplicate_slots, + ) + .await? + { + SlotSelection::AlreadyAllocated(seq) => { + return Ok(RegistrationOutcome::AlreadyAllocated { seq }); + } + SlotSelection::Free(seq) => seq, + }; + + let context = slot::derive_slot_context(period, seq); + let call = extrinsic::build_set_statement_store_account_call(period, seq, target); + let message = extension::build_proof_message(metadata, &call, chain_state)?; + let domain = proof::domain_for_ring_exponent(ring.exponent)?; + let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; + let as_resources_extra = extrinsic::build_as_resources_extra(&ring_proof, ring.ring_index); + let extrinsic = + extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; + + match rpc.submit_and_watch(&extrinsic).await { + Ok(block_hash) => { + return Ok(RegistrationOutcome::Registered { + block_hash, + seq, + ring_index: ring.ring_index, + }); + } + Err(err) => { + let err = err.to_string(); + if duplicate_submit_error(&err) { + skipped_duplicate_slots.push(seq); + continue; + } + return Err(err); + } + } + } +} + +fn duplicate_submit_error(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("priority is too low") + || message.contains("already imported") + || message.contains("temporarily banned") +} diff --git a/rust/crates/truapi-host-cli/src/alloc/dynamic.rs b/rust/crates/truapi-host-cli/src/alloc/dynamic.rs new file mode 100644 index 00000000..0fe11728 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/dynamic.rs @@ -0,0 +1,132 @@ +//! Minimal metadata-driven SCALE walker. +//! +//! Just enough to read one field out of a storage struct without a full dynamic +//! codec: `skip` advances a cursor past one value of a given type, and +//! `read_field_variant_name` walks a composite to a named field and returns its +//! enum variant name (used for `CollectionInfo.ring_size` -> `R2e9`/`R2e10`/`R2e14`). + +use parity_scale_codec::{Compact, Decode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + +/// Advance `input` past exactly one SCALE-encoded value of `type_id`. +pub fn skip(registry: &PortableRegistry, type_id: u32, input: &mut &[u8]) -> Result<(), String> { + let ty = registry + .resolve(type_id) + .ok_or_else(|| format!("unknown type id {type_id}"))?; + match &ty.type_def { + TypeDef::Composite(c) => { + for field in &c.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Tuple(t) => { + for field in &t.fields { + skip(registry, field.id, input)?; + } + } + TypeDef::Array(a) => { + for _ in 0..a.len { + skip(registry, a.type_param.id, input)?; + } + } + TypeDef::Sequence(s) => { + let len = read_compact(input)?; + for _ in 0..len { + skip(registry, s.type_param.id, input)?; + } + } + TypeDef::Variant(v) => { + let index = read_u8(input)?; + let variant = v + .variants + .iter() + .find(|var| var.index == index) + .ok_or_else(|| format!("unknown variant index {index}"))?; + for field in &variant.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Compact(_) => { + read_compact(input)?; + } + TypeDef::BitSequence(_) => { + let bits = read_compact(input)?; + advance(input, bits.div_ceil(8))?; + } + TypeDef::Primitive(p) => { + let len = match p { + TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => 1, + TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => 2, + TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => 4, + TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => 8, + TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => 16, + TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => 32, + // Length-prefixed UTF-8: compact byte length then the bytes. + TypeDefPrimitive::Str => read_compact(input)?, + }; + advance(input, len)?; + } + } + Ok(()) +} + +/// Walk composite `struct_type_id` to `field_name` and return the enum variant +/// name selected there (the field must be a fieldless/simple enum). +pub fn read_field_variant_name( + registry: &PortableRegistry, + struct_type_id: u32, + field_name: &str, + bytes: &[u8], +) -> Result { + let ty = registry + .resolve(struct_type_id) + .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; + let TypeDef::Composite(composite) = &ty.type_def else { + return Err(format!("type {struct_type_id} is not a composite")); + }; + + let mut input = bytes; + for field in &composite.fields { + if field.name.as_deref() == Some(field_name) { + let field_ty = registry + .resolve(field.ty.id) + .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; + let TypeDef::Variant(variant) = &field_ty.type_def else { + return Err(format!("field `{field_name}` is not an enum")); + }; + let index = read_u8(&mut input)?; + return variant + .variants + .iter() + .find(|var| var.index == index) + .map(|var| var.name.clone()) + .ok_or_else(|| format!("unknown variant index {index} for `{field_name}`")); + } + skip(registry, field.ty.id, &mut input)?; + } + Err(format!("field `{field_name}` not found")) +} + +/// Decode a SCALE compact-encoded length, advancing `input`. +fn read_compact(input: &mut &[u8]) -> Result { + let Compact(value) = Compact::::decode(input).map_err(|err| format!("compact: {err}"))?; + usize::try_from(value).map_err(|_| "compact length overflow".to_string()) +} + +/// Read one byte, advancing `input`. +fn read_u8(input: &mut &[u8]) -> Result { + let (&first, rest) = input + .split_first() + .ok_or_else(|| "unexpected end".to_string())?; + *input = rest; + Ok(first) +} + +/// Advance `input` by `n` bytes. +fn advance(input: &mut &[u8], n: usize) -> Result<(), String> { + if input.len() < n { + return Err(format!("need {n} bytes, have {}", input.len())); + } + *input = &input[n..]; + Ok(()) +} diff --git a/rust/crates/truapi-host-cli/src/alloc/extension.rs b/rust/crates/truapi-host-cli/src/alloc/extension.rs new file mode 100644 index 00000000..c2e925b1 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/extension.rs @@ -0,0 +1,370 @@ +//! Signed-extension encoding for the unsigned General (v5) `AsResources` +//! extrinsic, driven by live chain metadata. +//! +//! The extension **order** and per-extension type ids come from the runtime +//! metadata (`state_getMetadata`, V14/V15); the per-extension `extra` / +//! `additional_signed` bytes come from a name-keyed encoder mirroring +//! signing-bot `src/core/create-transaction.ts` `encodeSignedExtensions`, with a +//! generic default for the personhood extensions (all `Option`/void). +//! +//! Two concatenations are derived from the same encoded list: +//! - the ring-VRF proof message (`build_proof_message`) over the extensions +//! strictly *after* `AsResources` (host-spec inherited implication), and +//! - the full extrinsic body's `Σ extra` (see `extrinsic.rs`), over *all* +//! extensions with `AsResources` carrying `Some(AsResourcesInfo)`. + +use std::collections::HashMap; + +use blake2_rfc::blake2b::blake2b; +use frame_metadata::RuntimeMetadata; +use frame_metadata::RuntimeMetadataPrefixed; +use parity_scale_codec::{Compact, Decode, Encode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + +/// Signed-extension identifier that carries the `AsResources` authorization. +pub const AS_RESOURCES: &str = "AsResources"; + +/// Chain state needed to fill the standard signed extensions. +#[derive(Debug, Clone, Copy)] +pub struct ChainState { + /// Runtime `specVersion` (CheckSpecVersion implicit). + pub spec_version: u32, + /// Runtime `transactionVersion` (CheckTxVersion implicit). + pub transaction_version: u32, + /// Genesis block hash (CheckGenesis / CheckMortality implicit). + pub genesis_hash: [u8; 32], + /// Account nonce (CheckNonce extra); ignored by the unsigned path. + pub nonce: u32, +} + +/// A signed extension's identifier plus the type ids of its `extra` and +/// `additional_signed` fields, in metadata order. +struct ExtensionDef { + identifier: String, + extra_type: u32, + additional_signed_type: u32, +} + +/// A signed extension encoded to its `extra` and `additional_signed` bytes. +pub struct EncodedExtension { + /// SCALE-encoded `extra` (goes into the extrinsic body). + pub extra: Vec, + /// SCALE-encoded `additional_signed` (the implicit, part of the signed data). + pub additional_signed: Vec, +} + +/// Decoded metadata: the ordered signed-extension defs, the type registry, and +/// each storage entry's value type id (`(pallet, entry) -> type id`). +pub struct Metadata { + extensions: Vec, + registry: PortableRegistry, + storage_values: HashMap<(String, String), u32>, + constants: HashMap<(String, String), Vec>, +} + +/// Collect extensions, type registry, storage value types, and pallet constants +/// from a decoded V14/V15 metadata; `$set` is the version's `StorageEntryType`. +macro_rules! collect_metadata { + ($m:expr, $set:path) => {{ + let extensions = $m + .extrinsic + .signed_extensions + .iter() + .map(|e| ExtensionDef { + identifier: e.identifier.clone(), + extra_type: e.ty.id, + additional_signed_type: e.additional_signed.id, + }) + .collect(); + let mut storage_values = HashMap::new(); + let mut constants = HashMap::new(); + for pallet in &$m.pallets { + for constant in &pallet.constants { + constants.insert( + (pallet.name.clone(), constant.name.clone()), + constant.value.clone(), + ); + } + let Some(storage) = &pallet.storage else { + continue; + }; + for entry in &storage.entries { + use $set as EntryType; + let value_type = match &entry.ty { + EntryType::Plain(ty) => ty.id, + EntryType::Map { value, .. } => value.id, + }; + storage_values.insert((pallet.name.clone(), entry.name.clone()), value_type); + } + } + (extensions, $m.types, storage_values, constants) + }}; +} + +impl Metadata { + /// Decode `state_getMetadata` bytes (a `RuntimeMetadataPrefixed`, V14 or + /// V15) into the ordered signed-extension defs, type registry, and storage + /// value types. + pub fn decode(bytes: &[u8]) -> Result { + let prefixed = RuntimeMetadataPrefixed::decode(&mut &bytes[..]) + .map_err(|err| format!("metadata decode failed: {err}"))?; + let (extensions, registry, storage_values, constants) = match prefixed.1 { + RuntimeMetadata::V14(m) => collect_metadata!(m, frame_metadata::v14::StorageEntryType), + RuntimeMetadata::V15(m) => collect_metadata!(m, frame_metadata::v15::StorageEntryType), + other => return Err(format!("unsupported metadata version {}", other.version())), + }; + Ok(Self { + extensions, + registry, + storage_values, + constants, + }) + } + + /// The type registry, for dynamic decoding of storage values. + pub fn registry(&self) -> &PortableRegistry { + &self.registry + } + + /// The value type id of storage entry `pallet::entry`, if present. + pub fn storage_value_type(&self, pallet: &str, entry: &str) -> Option { + self.storage_values + .get(&(pallet.to_string(), entry.to_string())) + .copied() + } + + /// The SCALE-encoded value bytes of pallet constant `pallet::name`. + pub fn constant(&self, pallet: &str, name: &str) -> Option<&[u8]> { + self.constants + .get(&(pallet.to_string(), name.to_string())) + .map(Vec::as_slice) + } + + /// Encode every signed extension in metadata order. + pub fn encode_signed_extensions(&self, state: &ChainState) -> Vec { + self.extensions + .iter() + .map(|ext| { + let (extra, additional_signed) = self.encode_one(ext, state); + EncodedExtension { + extra, + additional_signed, + } + }) + .collect() + } + + /// The signed-extension identifiers, in metadata order. + #[cfg(test)] + pub fn extension_ids(&self) -> Vec<&str> { + self.extensions + .iter() + .map(|e| e.identifier.as_str()) + .collect() + } + + /// Encode a single extension's `(extra, additional_signed)`, mirroring the + /// signing-bot switch; unknown personhood extensions fall back to the + /// metadata type default (`Option` -> None, void -> empty). + fn encode_one(&self, ext: &ExtensionDef, state: &ChainState) -> (Vec, Vec) { + match ext.identifier.as_str() { + "CheckNonce" => (Compact(state.nonce).encode(), Vec::new()), + "CheckSpecVersion" => (Vec::new(), state.spec_version.to_le_bytes().to_vec()), + "CheckTxVersion" => (Vec::new(), state.transaction_version.to_le_bytes().to_vec()), + "CheckGenesis" => (Vec::new(), state.genesis_hash.to_vec()), + // extra = Era::Immortal (0x00); implicit = genesis hash. + "CheckMortality" => (vec![0x00], state.genesis_hash.to_vec()), + // extra = first variant `Disabled` (void) = 0x00. + "VerifyMultiSignature" => (vec![0x00], Vec::new()), + // extra = { tip: compact(0), asset_id: None } = 0x00 0x00. + "ChargeAssetTxPayment" => (vec![0x00, 0x00], Vec::new()), + // extra = bool false = 0x00. + "RestrictOrigins" => (vec![0x00], Vec::new()), + _ => ( + self.encode_default(ext.extra_type), + self.encode_default(ext.additional_signed_type), + ), + } + } + + /// Encode the "disabled" default value for a metadata type: `Option` -> None + /// (`0x00`), void/empty tuple -> empty, enums -> first variant, primitives + /// -> zero. Matches signing-bot `defaultValueForType`. + fn encode_default(&self, type_id: u32) -> Vec { + let Some(ty) = self.registry.resolve(type_id) else { + return Vec::new(); + }; + match &ty.type_def { + TypeDef::Composite(c) => c + .fields + .iter() + .flat_map(|f| self.encode_default(f.ty.id)) + .collect(), + TypeDef::Tuple(t) => t + .fields + .iter() + .flat_map(|f| self.encode_default(f.id)) + .collect(), + TypeDef::Variant(v) => { + // Option encodes None as 0x00. + if ty.path.segments.last().map(String::as_str) == Some("Option") { + return vec![0x00]; + } + match v.variants.iter().min_by_key(|var| var.index) { + None => Vec::new(), + Some(first) => { + let mut out = vec![first.index]; + for field in &first.fields { + out.extend(self.encode_default(field.ty.id)); + } + out + } + } + } + TypeDef::Array(a) => { + let elem = self.encode_default(a.type_param.id); + elem.repeat(a.len as usize) + } + // Sequences / strings / bit-sequences encode an empty run as compact(0). + TypeDef::Sequence(_) | TypeDef::BitSequence(_) => vec![0x00], + TypeDef::Compact(_) => vec![0x00], + TypeDef::Primitive(p) => match p { + TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => vec![0], + TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => { + vec![0; 4] + } + TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => vec![0; 2], + TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => vec![0; 8], + TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => vec![0; 16], + TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => vec![0; 32], + // Length-prefixed string: empty = compact(0). + TypeDefPrimitive::Str => vec![0x00], + }, + } + } + + /// Index of `AsResources` in the extension list, if present. + pub fn as_resources_index(&self) -> Option { + self.extensions + .iter() + .position(|e| e.identifier == AS_RESOURCES) + } +} + +/// Build the ring-VRF proof message for an `AsResources`-authorized call: +/// `blake2b256(0x00 ‖ call ‖ Σ tail.extra ‖ Σ tail.additional_signed)`, where +/// the tail is the extensions ordered strictly after `AsResources`. The leading +/// `0x00` is the General-transaction extension-version byte. +pub fn build_proof_message( + metadata: &Metadata, + call_data: &[u8], + state: &ChainState, +) -> Result<[u8; 32], String> { + let all = metadata.encode_signed_extensions(state); + let tail_start = metadata + .as_resources_index() + .map(|i| i + 1) + .ok_or_else(|| format!("{AS_RESOURCES} extension not found in metadata"))?; + let tail = &all[tail_start..]; + + let mut payload = Vec::with_capacity(1 + call_data.len()); + payload.push(0x00); + payload.extend_from_slice(call_data); + for ext in tail { + payload.extend_from_slice(&ext.extra); + } + for ext in tail { + payload.extend_from_slice(&ext.additional_signed); + } + Ok(blake2b256(&payload)) +} + +/// BLAKE2b-256 of `message`. +pub fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b(32, &[], message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fixture metadata captured from paseo-next-v2 (raw `RuntimeMetadataPrefixed`). + const FIXTURE: &[u8] = include_bytes!("../../tests/fixtures/paseo-next-v2-metadata.scale"); + + /// The known-answer chain state frozen alongside the fixture. + fn fixture_state() -> ChainState { + ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + } + } + + /// `Resources.set_statement_store_account(period=7, seq=0, target=0)`. + fn fixture_call() -> Vec { + let mut call = vec![0x3f, 0x0a]; + call.extend_from_slice(&7u32.to_le_bytes()); + call.extend_from_slice(&0u32.to_le_bytes()); + call.extend_from_slice(&[0u8; 32]); + call + } + + #[test] + fn proof_message_matches_frozen_known_answer() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let msg = build_proof_message(&metadata, &fixture_call(), &fixture_state()).unwrap(); + assert_eq!( + hex::encode(msg), + "1d2e6d8d8f421b0857097c6076115507432d66fea47ebe0c3be282a369f6743c", + ); + } + + #[test] + fn as_resources_tail_is_indices_10_through_20() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let idx = metadata.as_resources_index().unwrap(); + // AsResources sits at index 9; the proof tail is everything after it. + assert_eq!(idx, 9); + let ids = metadata.extension_ids(); + assert_eq!( + ids[idx + 1..].to_vec(), + vec![ + "AuthorizeCall", + "RestrictOrigins", + "CheckNonZeroSender", + "CheckSpecVersion", + "CheckTxVersion", + "CheckGenesis", + "CheckMortality", + "CheckNonce", + "CheckWeight", + "ChargeAssetTxPayment", + "StorageWeightReclaim", + ], + ); + } + + #[test] + fn dropping_the_version_byte_changes_the_hash() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let state = fixture_state(); + let call = fixture_call(); + let all = metadata.encode_signed_extensions(&state); + let tail = &all[metadata.as_resources_index().unwrap() + 1..]; + let mut without = call.clone(); + for e in tail { + without.extend_from_slice(&e.extra); + } + for e in tail { + without.extend_from_slice(&e.additional_signed); + } + assert_ne!( + build_proof_message(&metadata, &call, &state).unwrap(), + blake2b256(&without), + ); + } +} diff --git a/rust/crates/truapi-host-cli/src/alloc/extrinsic.rs b/rust/crates/truapi-host-cli/src/alloc/extrinsic.rs new file mode 100644 index 00000000..8d0dd928 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/extrinsic.rs @@ -0,0 +1,142 @@ +//! `Resources.set_statement_store_account` call + unsigned General (v5) +//! extrinsic assembly. Mirrors signing-bot `allocation.ts` / `extrinsic-submit.ts`. + +use parity_scale_codec::{Compact, Encode}; + +use super::extension::{ChainState, Metadata}; + +/// Pallet + call index for `Resources.set_statement_store_account` (63 / 10). +pub const SET_STATEMENT_STORE_ACCOUNT_CALL: [u8; 2] = [0x3f, 0x0a]; +/// `AsResourcesInfo::RegisterStatementStoreAllowance` variant index. +const REGISTER_STATEMENT_STORE_ALLOWANCE: u8 = 0x02; +/// `MembershipCollection::LitePeople` variant index. +const MEMBERSHIP_COLLECTION_LITE_PEOPLE: u8 = 0x01; +/// General-transaction preamble byte: `0b01` (General) | version 5. +const GENERAL_V5_PREAMBLE: u8 = 0x45; +/// Current signed-extension version byte. +const EXTENSION_VERSION: u8 = 0x00; +/// `Option::Some` discriminant for the `AsResources` extension `extra`. +const OPTION_SOME: u8 = 0x01; + +/// Encode `Resources.set_statement_store_account(period, seq, target)`: +/// `3f 0a ‖ period_u32LE ‖ seq_u32LE ‖ target[32]`. +pub fn build_set_statement_store_account_call(period: u32, seq: u32, target: &[u8; 32]) -> Vec { + let mut call = Vec::with_capacity(2 + 4 + 4 + 32); + call.extend_from_slice(&SET_STATEMENT_STORE_ACCOUNT_CALL); + call.extend_from_slice(&period.to_le_bytes()); + call.extend_from_slice(&seq.to_le_bytes()); + call.extend_from_slice(target); + call +} + +/// Encode the `AsResources` extension `extra` for a statement-store allowance: +/// `Some(RegisterStatementStoreAllowance { proof, ring_index, LitePeople })`. +pub fn build_as_resources_extra(proof: &[u8], ring_index: u32) -> Vec { + let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 1); + extra.push(OPTION_SOME); + extra.push(REGISTER_STATEMENT_STORE_ALLOWANCE); + extra.extend_from_slice(&Compact(proof.len() as u32).encode()); + extra.extend_from_slice(proof); + extra.extend_from_slice(&ring_index.to_le_bytes()); + extra.push(MEMBERSHIP_COLLECTION_LITE_PEOPLE); + extra +} + +/// Assemble the unsigned General (v5) extrinsic: +/// `compact(len) ‖ 0x45 ‖ 0x00 ‖ Σ(all extra, AsResources = Some(info)) ‖ call`. +pub fn build_unsigned_extrinsic( + metadata: &Metadata, + state: &ChainState, + call_data: &[u8], + as_resources_extra: &[u8], +) -> Result, String> { + let all = metadata.encode_signed_extensions(state); + let as_resources_index = metadata + .as_resources_index() + .ok_or_else(|| "AsResources extension not found in metadata".to_string())?; + + let mut body = vec![GENERAL_V5_PREAMBLE, EXTENSION_VERSION]; + for (i, ext) in all.iter().enumerate() { + if i == as_resources_index { + body.extend_from_slice(as_resources_extra); + } else { + body.extend_from_slice(&ext.extra); + } + } + body.extend_from_slice(call_data); + + let mut extrinsic = Compact(body.len() as u32).encode(); + extrinsic.extend_from_slice(&body); + Ok(extrinsic) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE: &[u8] = include_bytes!("../../tests/fixtures/paseo-next-v2-metadata.scale"); + + fn fixture_state() -> ChainState { + ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + } + } + + #[test] + fn call_layout_is_pallet_call_period_seq_target() { + let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); + assert_eq!( + call, + [ + vec![0x3f, 0x0a], + 7u32.to_le_bytes().to_vec(), + 0u32.to_le_bytes().to_vec(), + vec![0u8; 32], + ] + .concat() + ); + } + + #[test] + fn as_resources_extra_wraps_proof_as_bytes() { + let proof = vec![0xEE; 785]; + let extra = build_as_resources_extra(&proof, 3); + // Some(0x01) ‖ variant(0x02) ‖ compact(785)=0x45,0x0c ‖ 785 bytes ‖ ringIndex LE ‖ LitePeople. + assert_eq!(&extra[..2], &[0x01, 0x02]); + assert_eq!(&extra[2..4], &Compact(785u32).encode()[..]); + assert_eq!(&extra[4..4 + 785], &proof[..]); + assert_eq!(&extra[4 + 785..4 + 785 + 4], &3u32.to_le_bytes()); + assert_eq!(extra[4 + 785 + 4], MEMBERSHIP_COLLECTION_LITE_PEOPLE); + } + + #[test] + fn extrinsic_has_general_v5_preamble_and_embeds_call() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); + let extra = build_as_resources_extra(&vec![0xEE; 785], 0); + let xt = build_unsigned_extrinsic(&metadata, &fixture_state(), &call, &extra).unwrap(); + + // Strip the compact length prefix and check the body head + tail. + let body = &xt[compact_prefix_len(&xt)..]; + assert_eq!(&body[..2], &[GENERAL_V5_PREAMBLE, EXTENSION_VERSION]); + assert_eq!(&body[body.len() - call.len()..], &call[..]); + // The Some(info) extra appears verbatim in the body. + assert!( + body.windows(extra.len()).any(|w| w == extra), + "AsResources Some(info) extra should appear in the body", + ); + } + + /// Length of the SCALE compact prefix at the head of `xt`. + fn compact_prefix_len(xt: &[u8]) -> usize { + match xt[0] & 0b11 { + 0b00 => 1, + 0b01 => 2, + 0b10 => 4, + _ => 5, + } + } +} diff --git a/rust/crates/truapi-host-cli/src/alloc/proof.rs b/rust/crates/truapi-host-cli/src/alloc/proof.rs new file mode 100644 index 00000000..b9dc567f --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/proof.rs @@ -0,0 +1,111 @@ +//! Bandersnatch ring-VRF one-shot membership proof (prover side). +//! +//! Wraps `verifiable`'s prover-gated `open` + `create` into the single-shot +//! proof a `RegisterStatementStoreAllowance` needs: prove that our member key is +//! in the LitePeople ring, bound to a slot `context` and the extrinsic proof +//! `message`. Mirrors signing-bot `ring-proof.ts` `oneShotProof`. + +use verifiable::GenerateVerifiable; +use verifiable::ring::RingDomainSize; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +/// A single-context ring-VRF signature is exactly 785 bytes. +pub const RING_VRF_PROOF_LEN: usize = 785; + +/// Map an on-chain `RingExponent` (9 / 10 / 14) to the FFT domain size +/// (power = exponent + 2). +pub fn domain_for_ring_exponent(exponent: u8) -> Result { + match exponent { + 9 => Ok(RingDomainSize::Domain11), + 10 => Ok(RingDomainSize::Domain12), + 14 => Ok(RingDomainSize::Domain16), + other => Err(format!("unsupported ring exponent {other}")), + } +} + +/// The ring member key for a bandersnatch entropy (`blake2b256(bip39_entropy)`). +pub fn member_key(entropy: [u8; 32]) -> [u8; 32] { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + BandersnatchVrfVerifiable::member_from_secret(&secret) +} + +/// Produce the 785-byte ring-VRF membership proof over `members` (already +/// sliced to the ring's included prefix), bound to `context` and `message`. +/// +/// `entropy` is the bandersnatch entropy; its member key must be present in +/// `members` or `open` fails with `NotInRing`. +pub fn ring_vrf_proof( + domain: RingDomainSize, + entropy: [u8; 32], + members: &[[u8; 32]], + context: &[u8], + message: &[u8], +) -> Result, String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let member = BandersnatchVrfVerifiable::member_from_secret(&secret); + let commitment = BandersnatchVrfVerifiable::open(domain, &member, members.iter().copied()) + .map_err(|err| format!("ring-VRF open failed: {err:?}"))?; + let (proof, _alias) = BandersnatchVrfVerifiable::create(commitment, &secret, context, message) + .map_err(|err| format!("ring-VRF create failed: {err:?}"))?; + let bytes = proof.into_inner(); + if bytes.len() != RING_VRF_PROOF_LEN { + return Err(format!( + "ring-VRF proof is {} bytes, expected {RING_VRF_PROOF_LEN}", + bytes.len() + )); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exponent_maps_to_domain() { + assert_eq!( + domain_for_ring_exponent(9).unwrap(), + RingDomainSize::Domain11 + ); + assert_eq!( + domain_for_ring_exponent(10).unwrap(), + RingDomainSize::Domain12 + ); + assert_eq!( + domain_for_ring_exponent(14).unwrap(), + RingDomainSize::Domain16 + ); + assert!(domain_for_ring_exponent(11).is_err()); + } + + #[test] + fn proof_is_785_bytes_for_a_single_member_ring() { + let entropy = [0x11u8; 32]; + let member = member_key(entropy); + let members = vec![member]; + let proof = ring_vrf_proof( + RingDomainSize::Domain11, + entropy, + &members, + b"SSS_SLOT:test-context-padding..", + &[0x42; 32], + ) + .unwrap(); + assert_eq!(proof.len(), RING_VRF_PROOF_LEN); + } + + #[test] + fn open_fails_when_member_absent_from_ring() { + let entropy = [0x11u8; 32]; + let other = member_key([0x22u8; 32]); + let err = ring_vrf_proof( + RingDomainSize::Domain11, + entropy, + &[other], + b"SSS_SLOT:test-context-padding..", + &[0x42; 32], + ) + .unwrap_err(); + assert!(err.contains("open failed"), "unexpected error: {err}"); + } +} diff --git a/rust/crates/truapi-host-cli/src/alloc/ring.rs b/rust/crates/truapi-host-cli/src/alloc/ring.rs new file mode 100644 index 00000000..aac6403d --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/ring.rs @@ -0,0 +1,164 @@ +//! LitePeople ring parameters from the People chain (`Members` pallet). +//! +//! Reads the on-chain ring so the membership proof is built against the same +//! members the runtime verifies against: the baked-in `included` prefix of the +//! current ring. Mirrors signing-bot `ring-proof.ts`. + +use parity_scale_codec::{Compact, Decode}; +use sp_crypto_hashing::{blake2_128, twox_64, twox_128}; + +use super::dynamic::read_field_variant_name; +use super::extension::Metadata; +use super::rpc::RpcClient; + +/// LitePeople collection identifier: ASCII, exactly 32 bytes. +const LITE_PEOPLE_IDENTIFIER: &[u8; 32] = b"pop:polkadot.network/people-lite"; +/// Ring member public key length. +const MEMBER_LEN: usize = 32; + +/// On-chain LitePeople ring parameters for building a verifying proof. +pub struct RingParams { + /// Ring members, sliced to the baked-in `included` prefix. + pub members: Vec<[u8; 32]>, + /// Ring size exponent (9 / 10 / 14). + pub exponent: u8, + /// Ring index these members belong to. + pub ring_index: u32, +} + +/// `Members.CurrentRingIndex[id]` storage key. +fn current_ring_index_key() -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"CurrentRingIndex").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + ] + .concat() +} + +/// `Members.Collections[id]` storage key. +fn collections_key() -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Collections").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + ] + .concat() +} + +/// `Members.RingKeysStatus[(id, ring_index)]` storage key. +fn ring_keys_status_key(ring_index: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeysStatus").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + ] + .concat() +} + +/// `Members.RingKeys[(id, ring_index, page)]` storage key. +fn ring_keys_key(ring_index: u32, page: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeys").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + &twox_64_concat(&page.to_le_bytes()), + ] + .concat() +} + +/// `Blake2_128Concat(x)` = `blake2_128(x) ‖ x`. +pub(super) fn blake2_128_concat(x: &[u8]) -> Vec { + [blake2_128(x).as_slice(), x].concat() +} + +/// `Twox64Concat(x)` = `twox_64(x) ‖ x`. +fn twox_64_concat(x: &[u8]) -> Vec { + [twox_64(x).as_slice(), x].concat() +} + +/// Map a `RingExponent` variant name to its exponent. +fn ring_exponent_from_name(name: &str) -> Result { + match name { + "R2e9" => Ok(9), + "R2e10" => Ok(10), + "R2e14" => Ok(14), + other => Err(format!("unsupported RingExponent variant `{other}`")), + } +} + +/// Read the current LitePeople ring index (absent => 0). +pub async fn read_current_ring_index(rpc: &RpcClient) -> Result { + match rpc + .get_storage(¤t_ring_index_key()) + .await + .map_err(|e| e.to_string())? + { + Some(bytes) => u32::decode(&mut &bytes[..]).map_err(|e| format!("ring index: {e}")), + None => Ok(0), + } +} + +/// Read the LitePeople ring size exponent from `Collections[LitePeople].ring_size`. +/// This is a chain constant, so read it once and reuse across ring indices. +pub async fn read_ring_exponent(rpc: &RpcClient, metadata: &Metadata) -> Result { + let collection = rpc + .get_storage(&collections_key()) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Members.Collections[LitePeople] missing".to_string())?; + let value_type = metadata + .storage_value_type("Members", "Collections") + .ok_or_else(|| "Members.Collections type not in metadata".to_string())?; + let variant = + read_field_variant_name(metadata.registry(), value_type, "ring_size", &collection)?; + ring_exponent_from_name(&variant) +} + +/// Read the members of `ring_index`, sliced to the baked-in `included` prefix. +pub async fn read_ring_members_at( + rpc: &RpcClient, + ring_index: u32, +) -> Result, String> { + // 1. Page through RingKeys collecting raw 32-byte members. + let mut members = Vec::new(); + for page in 0.. { + let Some(bytes) = rpc + .get_storage(&ring_keys_key(ring_index, page)) + .await + .map_err(|e| e.to_string())? + else { + break; + }; + let mut cursor = &bytes[..]; + let Compact(len) = + Compact::::decode(&mut cursor).map_err(|e| format!("ring keys len: {e}"))?; + if len == 0 { + break; + } + for i in 0..len as usize { + let start = i * MEMBER_LEN; + let member: [u8; 32] = cursor + .get(start..start + MEMBER_LEN) + .ok_or_else(|| "ring keys page truncated".to_string())? + .try_into() + .expect("slice is 32 bytes"); + members.push(member); + } + } + + // 2. Slice to the baked-in `included` prefix (absent status => all included). + if let Some(status) = rpc + .get_storage(&ring_keys_status_key(ring_index)) + .await + .map_err(|e| e.to_string())? + { + // RingStatus = { total: u32 LE, included: u32 LE, .. }. + let included = u32::decode(&mut &status[4..]).map_err(|e| format!("ring status: {e}"))?; + members.truncate(included as usize); + } + + Ok(members) +} diff --git a/rust/crates/truapi-host-cli/src/alloc/rpc.rs b/rust/crates/truapi-host-cli/src/alloc/rpc.rs new file mode 100644 index 00000000..e6660c4c --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/rpc.rs @@ -0,0 +1,116 @@ +//! JSON-RPC helpers for statement-store allowance registration. +//! +//! Keep the CLI diagnostic path on the same `subxt-rpcs` transport surface as +//! the runtime code instead of hand-rolling request ids, subscriptions, and +//! websocket framing here. + +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde_json::Value; +use subxt_rpcs::client::{RpcClient as SubxtRpcClient, RpcParams, rpc_params}; +use tokio::time::timeout; + +/// Timeout for an allowance registration extrinsic to finalize. +const SUBMIT_TIMEOUT: Duration = Duration::from_secs(120); + +/// A People-chain JSON-RPC connection. +pub struct RpcClient { + inner: SubxtRpcClient, +} + +impl RpcClient { + /// Open a JSON-RPC connection to `url`. + pub async fn connect(url: &str) -> Result { + let inner = SubxtRpcClient::from_insecure_url(url) + .await + .with_context(|| format!("connect {url}"))?; + Ok(Self { inner }) + } + + /// Call `method` with `params`, returning the result value. + pub async fn call(&self, method: &str, params: Value) -> Result { + self.inner + .request(method, value_to_params(params)?) + .await + .with_context(|| format!("rpc {method}")) + } + + /// `state_getStorage(key)` -> raw value bytes, or `None` if absent. + pub async fn get_storage(&self, key: &[u8]) -> Result>> { + let key_hex = format!("0x{}", hex::encode(key)); + match self + .inner + .request::("state_getStorage", rpc_params![key_hex]) + .await + .context("rpc state_getStorage")? + { + Value::String(hex_value) => Ok(Some(decode_hex(&hex_value)?)), + _ => Ok(None), + } + } + + /// Submit an extrinsic and wait for `finalized`; returns the block hash. + pub async fn submit_and_watch(&self, extrinsic: &[u8]) -> Result { + let extrinsic_hex = format!("0x{}", hex::encode(extrinsic)); + let mut subscription = self + .inner + .subscribe::( + "author_submitAndWatchExtrinsic", + rpc_params![extrinsic_hex], + "author_unwatchExtrinsic", + ) + .await + .context("rpc author_submitAndWatchExtrinsic")?; + let started = Instant::now(); + + loop { + let remaining = SUBMIT_TIMEOUT + .checked_sub(started.elapsed()) + .ok_or_else(|| anyhow!("timed out waiting for author_submitAndWatchExtrinsic"))?; + let status = timeout(remaining, subscription.next()) + .await + .map_err(|_| anyhow!("timed out waiting for author_submitAndWatchExtrinsic"))? + .ok_or_else(|| anyhow!("author_submitAndWatchExtrinsic subscription ended"))? + .context("author_submitAndWatchExtrinsic update")?; + match extrinsic_status(&status) { + ExtrinsicStatus::Finalized(hash) => return Ok(hash), + ExtrinsicStatus::Rejected(reason) => bail!("extrinsic {reason}"), + ExtrinsicStatus::Pending => {} + } + } + } +} + +enum ExtrinsicStatus { + Finalized(String), + Rejected(String), + Pending, +} + +fn extrinsic_status(status: &Value) -> ExtrinsicStatus { + if let Some(hash) = status.get("finalized").and_then(Value::as_str) { + return ExtrinsicStatus::Finalized(hash.to_string()); + } + for key in ["invalid", "dropped", "usurped", "finalityTimeout"] { + if status.get(key).is_some() { + return ExtrinsicStatus::Rejected(key.to_string()); + } + } + ExtrinsicStatus::Pending +} + +fn value_to_params(value: Value) -> Result { + let Value::Array(values) = value else { + bail!("RPC params must be a JSON array"); + }; + let mut params = RpcParams::new(); + for value in values { + params.push(value).context("serialize RPC params")?; + } + Ok(params) +} + +fn decode_hex(value: &str) -> Result> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)).context("decode hex storage value") +} diff --git a/rust/crates/truapi-host-cli/src/alloc/slot.rs b/rust/crates/truapi-host-cli/src/alloc/slot.rs new file mode 100644 index 00000000..fd1fb59f --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/slot.rs @@ -0,0 +1,131 @@ +//! StatementStore allowance slot selection. +//! +//! An allowance is claimed at `(period, seq)`. The slot is bound to a 32-byte +//! `SSS_SLOT` context; occupancy is read from +//! `Resources.StatementStoreAllowances[period][alias]`, where the alias is +//! derived from OUR bandersnatch entropy in that slot context. Mirrors +//! signing-bot `allowance.ts` / `allowance-slots.ts`. + +use sp_crypto_hashing::twox_128; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use super::extension::Metadata; +use super::ring::blake2_128_concat; +use super::rpc::RpcClient; + +/// StatementStore allowance period: one UTC day, in seconds. +pub const STATEMENT_STORE_PERIOD_SECONDS: u64 = 86_400; + +/// The current allowance period for `now_seconds`. +pub fn current_period(now_seconds: u64) -> u32 { + (now_seconds / STATEMENT_STORE_PERIOD_SECONDS) as u32 +} + +/// Derive the 32-byte StatementStore slot context: +/// `"SSS_SLOT:" ‖ u32be(period) ‖ u32be(seq) ‖ 0x20 fill`. +pub fn derive_slot_context(period: u32, seq: u32) -> [u8; 32] { + let mut ctx = [0x20u8; 32]; + ctx[..9].copy_from_slice(b"SSS_SLOT:"); + ctx[9..13].copy_from_slice(&period.to_be_bytes()); + ctx[13..17].copy_from_slice(&seq.to_be_bytes()); + ctx +} + +/// The slot alias for our `entropy` at `(period, seq)`. +pub fn slot_alias(entropy: [u8; 32], period: u32, seq: u32) -> Result<[u8; 32], String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let context = derive_slot_context(period, seq); + BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("alias_in_context failed: {err:?}")) +} + +/// `Resources.StatementStoreAllowances[period][alias]` storage key. +/// key1 = Identity(u32be period); key2 = Blake2_128Concat(alias). +fn statement_store_allowance_key(period: u32, alias: &[u8; 32]) -> Vec { + [ + twox_128(b"Resources").as_slice(), + twox_128(b"StatementStoreAllowances").as_slice(), + &period.to_be_bytes(), + &blake2_128_concat(alias), + ] + .concat() +} + +/// Max StatementStore slots per period from `Resources.LiteStmtStoreSlotsPerPeriod`. +fn max_slots(metadata: &Metadata) -> Result { + let bytes = metadata + .constant("Resources", "LiteStmtStoreSlotsPerPeriod") + .ok_or_else(|| "Resources.LiteStmtStoreSlotsPerPeriod constant missing".to_string())?; + let mut buf = [0u8; 4]; + let n = bytes.len().min(4); + buf[..n].copy_from_slice(&bytes[..n]); + Ok(u32::from_le_bytes(buf)) +} + +/// The account id occupying a slot entry, if the storage value is present. +/// Entry = `account_id(32) ‖ seq(u32 LE) ‖ since(u64 LE)`. +fn entry_account_id(bytes: &[u8]) -> Option<[u8; 32]> { + bytes.get(..32).map(|s| s.try_into().expect("32 bytes")) +} + +/// Outcome of scanning for a slot to register `target` in. +pub enum SlotSelection { + /// A free `seq` we should claim. + Free(u32), + /// `target` already holds `seq` this period; no registration needed. + AlreadyAllocated(u32), +} + +/// Scan slots `0..max` for `period`, returning the first non-excluded free seq +/// (or detecting that `target` already holds one). `entropy` is our +/// bandersnatch entropy. +pub async fn scan_slot_excluding( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + period: u32, + target: &[u8; 32], + excluded: &[u32], +) -> Result { + let max = max_slots(metadata)?; + let mut first_free: Option = None; + for seq in 0..max { + let alias = slot_alias(entropy, period, seq)?; + let key = statement_store_allowance_key(period, &alias); + match rpc.get_storage(&key).await.map_err(|e| e.to_string())? { + None => { + if first_free.is_none() && !excluded.contains(&seq) { + first_free = Some(seq); + } + } + Some(bytes) => { + if entry_account_id(&bytes) == Some(*target) { + return Ok(SlotSelection::AlreadyAllocated(seq)); + } + } + } + } + first_free + .map(SlotSelection::Free) + .ok_or_else(|| format!("no free StatementStore slot in period {period} (max {max})")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slot_context_layout() { + let ctx = derive_slot_context(7, 3); + assert_eq!(&ctx[..9], b"SSS_SLOT:"); + assert_eq!(&ctx[9..13], &7u32.to_be_bytes()); + assert_eq!(&ctx[13..17], &3u32.to_be_bytes()); + assert!(ctx[17..].iter().all(|&b| b == 0x20)); + } + + #[test] + fn period_is_utc_day_index() { + assert_eq!(current_period(86_400 * 20_000 + 5), 20_000); + } +} diff --git a/rust/crates/truapi-host-cli/src/attestation.rs b/rust/crates/truapi-host-cli/src/attestation.rs new file mode 100644 index 00000000..c8dbdd8d --- /dev/null +++ b/rust/crates/truapi-host-cli/src/attestation.rs @@ -0,0 +1,227 @@ +//! Lite-username attestation against the People-chain identity backend. +//! +//! Ports signing-bot `attestation.ts`: fetch the backend verifier, build the +//! client proofs (`truapi_server::host_logic::attestation`), POST them to +//! `/usernames`, then poll People-chain `Resources.Consumers` until the record +//! lands. Registers the signing host's root account so the paired host can +//! resolve its username via `get_user_id`. + +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use serde_json::{Value, json}; +use subxt_rpcs::client::{RpcClient, rpc_params}; +use tracing::{info, warn}; +use truapi_server::host_logic::attestation::build_lite_registration; +use truapi_server::host_logic::identity::{ + decode_people_identity, resources_consumers_storage_key, +}; +use truapi_server::host_logic::product_account::{ + derive_root_keypair_from_entropy, derive_sr25519_hard_path, product_public_key_to_address, +}; + +/// Inputs for one attestation run. +pub struct AttestConfig { + /// Identity backend base URL including `/api/v1`. + pub backend_base: String, + /// People-chain WebSocket URL for the `Resources.Consumers` poll. + pub people_ws: String, + /// BIP-39 entropy of the signing host's root account. + pub entropy: Vec, + /// Requested lite username base (6+ lowercase letters, no digits). + pub username_base: String, +} + +/// Check whether a lite username base is available through the identity +/// backend. The username must be the base form without the digit suffix. +pub async fn lite_username_available(backend_base: &str, username_base: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + let url = format!("{backend_base}/usernames/available"); + let body = json!({ "usernames": [username_base] }); + let response = client + .post(&url) + .json(&body) + .send() + .await + .with_context(|| format!("POST {url}"))? + .error_for_status() + .with_context(|| format!("username availability check failed for {username_base}"))?; + let body: Value = response + .json() + .await + .context("decoding availability response")?; + Ok(body + .get(username_base) + .and_then(Value::as_str) + .is_some_and(|status| status == "AVAILABLE")) +} + +/// Register (or confirm) the signing host's lite username and wait until the +/// People-chain `Resources.Consumers` record exists. Returns the candidate +/// account's SS58 address. +pub async fn attest(config: &AttestConfig) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + + let verifier = fetch_verifier(&client, &config.backend_base).await?; + let registration = build_lite_registration(&config.entropy, verifier, &config.username_base) + .map_err(|reason| anyhow::anyhow!("failed to build registration params: {reason}"))?; + info!( + candidate = %registration.candidate_account_id, + "attesting lite username '{}'", + config.username_base + ); + + submit_registration( + &client, + &config.backend_base, + &config.username_base, + ®istration, + ) + .await?; + + let storage_key = format!( + "0x{}", + hex::encode(resources_consumers_storage_key( + ®istration.candidate_public_key + )) + ); + wait_for_consumer_record(&config.people_ws, &storage_key).await?; + info!("lite username registered and confirmed on-chain"); + Ok(registration.candidate_account_id) +} + +/// Probe the People chain for which derivation of `entropy` (bare root, +/// `//wallet`, `//wallet//sso`) has a `Resources.Consumers` record, printing +/// the account and decoded username. Used to confirm a pre-onboarded account. +pub async fn check_identity(people_ws: &str, entropy: &[u8]) -> Result<()> { + let root = derive_root_keypair_from_entropy(entropy) + .map_err(|err| anyhow::anyhow!("invalid entropy: {err}"))?; + let wallet = derive_sr25519_hard_path(entropy, &["wallet"]) + .map_err(|err| anyhow::anyhow!("//wallet derivation failed: {err}"))?; + let wallet_sso = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|err| anyhow::anyhow!("//wallet//sso derivation failed: {err}"))?; + + for (label, public) in [ + ("", root.public.to_bytes()), + ("//wallet", wallet.public.to_bytes()), + ("//wallet//sso", wallet_sso.public.to_bytes()), + ] { + let key = format!( + "0x{}", + hex::encode(resources_consumers_storage_key(&public)) + ); + let address = product_public_key_to_address(public); + match query_storage(people_ws, &key).await { + Ok(Some(value)) => { + let decoded = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) + .ok() + .and_then(|bytes| decode_people_identity(&bytes).ok()); + let username = decoded + .and_then(|id| id.full_username.or(id.lite_username)) + .unwrap_or_else(|| "".to_string()); + println!("IDENTITY_FOUND path={label} account={address} username={username}"); + } + Ok(None) => println!("IDENTITY_NONE path={label} account={address}"), + Err(err) => println!("IDENTITY_ERROR path={label} account={address} error={err}"), + } + } + Ok(()) +} + +async fn fetch_verifier(client: &reqwest::Client, backend_base: &str) -> Result<[u8; 32]> { + let url = format!("{backend_base}/attester"); + let body: Value = client + .get(&url) + .send() + .await + .with_context(|| format!("GET {url}"))? + .error_for_status()? + .json() + .await + .context("decoding attester response")?; + let hex_value = body + .get("attester") + .and_then(Value::as_str) + .context("attester response missing 'attester' field")?; + let bytes = hex::decode(hex_value.strip_prefix("0x").unwrap_or(hex_value)) + .context("attester is not valid hex")?; + <[u8; 32]>::try_from(bytes) + .map_err(|bytes| anyhow::anyhow!("attester must be 32 bytes, got {}", bytes.len())) +} + +async fn submit_registration( + client: &reqwest::Client, + backend_base: &str, + username_base: &str, + reg: &truapi_server::host_logic::attestation::LiteRegistration, +) -> Result<()> { + let url = format!("{backend_base}/usernames"); + let body = json!({ + "username": username_base, + "candidateAccountId": reg.candidate_account_id, + "candidateSignature": hex0x(®.candidate_signature), + "ringVrfKey": hex0x(®.ring_vrf_key), + "proofOfOwnership": hex0x(®.proof_of_ownership), + "identifierKey": hex0x(®.identifier_key), + "consumerRegistrationSignature": hex0x(®.consumer_registration_signature), + }); + let response = client + .post(&url) + .json(&body) + .send() + .await + .with_context(|| format!("POST {url}"))?; + let status = response.status(); + if status.is_success() { + let text = response.text().await.unwrap_or_default(); + info!(%status, body = %text, "POST /usernames accepted"); + return Ok(()); + } + let text = response.text().await.unwrap_or_default(); + // Already-registered is a soft success; the on-chain poll confirms it. + if text.contains("already") || text.contains("AlreadyRegistered") || text.contains("duplicate") + { + warn!(%status, "username already registered; confirming on-chain"); + return Ok(()); + } + bail!("username registration failed ({status}): {text}"); +} + +fn hex0x(bytes: &[u8]) -> String { + format!("0x{}", hex::encode(bytes)) +} + +async fn wait_for_consumer_record(people_ws: &str, storage_key: &str) -> Result<()> { + // First-time lite registration is backend-async and can take minutes + // (ring onboarding). The record is permanent once written, so later runs + // resolve on the first poll. + const MAX_ATTEMPTS: usize = 90; + for attempt in 1..=MAX_ATTEMPTS { + match query_storage(people_ws, storage_key).await { + Ok(Some(_)) => return Ok(()), + Ok(None) => info!("Resources.Consumers poll {attempt}/{MAX_ATTEMPTS}: empty"), + Err(err) => warn!(%err, "Resources.Consumers poll attempt {attempt} failed"), + } + if attempt < MAX_ATTEMPTS { + tokio::time::sleep(Duration::from_secs(4)).await; + } + } + bail!("Resources.Consumers record did not appear after attestation") +} + +/// One `state_getStorage` request over a fresh RPC connection; returns the value +/// hex when present. +async fn query_storage(people_ws: &str, storage_key: &str) -> Result> { + let rpc = RpcClient::from_insecure_url(people_ws) + .await + .with_context(|| format!("connect {people_ws}"))?; + let value = rpc + .request::("state_getStorage", rpc_params![storage_key]) + .await + .context("rpc state_getStorage")?; + Ok(value.as_str().map(str::to_string)) +} diff --git a/rust/crates/truapi-host-cli/src/chain.rs b/rust/crates/truapi-host-cli/src/chain.rs new file mode 100644 index 00000000..7424da37 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/chain.rs @@ -0,0 +1,155 @@ +//! Native WebSocket `ChainProvider` / `JsonRpcConnection`. +//! +//! The headless hosts reach the real People-chain statement store over +//! WebSocket JSON-RPC (the same node an iOS/web client uses). Every `connect` +//! opens a fresh socket; the runtime's `HostRpcClient` sits on top and speaks +//! statement-store RPC. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use async_trait::async_trait; +use futures::stream::BoxStream; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::{broadcast, mpsc}; +use tokio_stream::wrappers::BroadcastStream; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; +use tracing::debug; +use truapi::v01; +use truapi_platform::{ChainProvider, JsonRpcConnection}; + +use crate::network::ChainEndpoint; + +/// Broadcast backlog for inbound JSON-RPC frames per connection. +const INBOUND_CHANNEL_CAPACITY: usize = 1024; + +/// Chain provider that maps a requested genesis hash to a WebSocket endpoint. +/// +/// The all-zero genesis (the headless SSO sentinel) and any unmapped genesis +/// fall back to the People-chain statement store; the Asset Hub and Bulletin +/// genesis hashes route to their own nodes (opt-in) for live playground +/// examples. +pub struct WsChainProvider { + fallback_url: String, + by_genesis: HashMap<[u8; 32], String>, +} + +impl WsChainProvider { + pub fn new(fallback_url: impl Into, live_chain_endpoints: &[ChainEndpoint]) -> Self { + // The fallback is the People-chain statement store, which serves the + // SSO/identity path directly. Asset Hub routing (for the `Chain/*` + // examples) is opt-in; when off, those genesis requests fall back to the + // People node, which does not serve Asset Hub chainHead, so they fail + // cleanly without disturbing the SSO/signer path. + let by_genesis = if std::env::var("E2E_LIVE_CHAIN").as_deref() == Ok("1") { + live_chain_endpoints + .iter() + .map(|endpoint| (endpoint.genesis, endpoint.ws.to_string())) + .collect() + } else { + HashMap::new() + }; + Self { + fallback_url: fallback_url.into(), + by_genesis, + } + } + + fn url_for(&self, genesis_hash: &[u8; 32]) -> &str { + self.by_genesis + .get(genesis_hash) + .map(String::as_str) + .unwrap_or(&self.fallback_url) + } +} + +#[async_trait] +impl ChainProvider for WsChainProvider { + async fn connect( + &self, + genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + let url = self.url_for(&genesis_hash); + debug!(genesis = %hex::encode(genesis_hash), %url, "chain connect"); + let connection = WsJsonRpcConnection::connect(url) + .await + .map_err(|reason| v01::GenericError { reason })?; + Ok(Box::new(connection)) + } +} + +/// One WebSocket JSON-RPC connection: outbound requests are queued to a writer +/// task, inbound frames are broadcast to every `responses()` stream. +pub struct WsJsonRpcConnection { + outbound: mpsc::UnboundedSender, + inbound: broadcast::Sender, + closed: Arc, +} + +impl WsJsonRpcConnection { + async fn connect(url: &str) -> Result { + let (stream, _response) = connect_async(url) + .await + .map_err(|err| format!("statement-store websocket connect failed: {err}"))?; + let (mut write, mut read) = stream.split(); + let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); + let (inbound_tx, _inbound_rx) = broadcast::channel(INBOUND_CHANNEL_CAPACITY); + let closed = Arc::new(AtomicBool::new(false)); + + tokio::spawn(async move { + while let Some(message) = outbound_rx.recv().await { + if write.send(message).await.is_err() { + break; + } + } + let _ = write.close().await; + }); + + let reader_inbound = inbound_tx.clone(); + let reader_closed = closed.clone(); + tokio::spawn(async move { + while let Some(message) = read.next().await { + match message { + Ok(Message::Text(text)) => { + let _ = reader_inbound.send(text.to_string()); + } + Ok(Message::Binary(bytes)) => { + if let Ok(text) = String::from_utf8(bytes.to_vec()) { + let _ = reader_inbound.send(text); + } + } + Ok(Message::Close(_)) | Err(_) => break, + Ok(_) => {} + } + } + reader_closed.store(true, Ordering::Release); + }); + + Ok(Self { + outbound: outbound_tx, + inbound: inbound_tx, + closed, + }) + } +} + +impl JsonRpcConnection for WsJsonRpcConnection { + fn send(&self, request: String) { + if self.closed.load(Ordering::Acquire) { + return; + } + let _ = self.outbound.send(Message::Text(request)); + } + + fn responses(&self) -> BoxStream<'static, String> { + BroadcastStream::new(self.inbound.subscribe()) + .filter_map(|item| async move { item.ok() }) + .boxed() + } + + fn close(&self) { + self.closed.store(true, Ordering::Release); + } +} diff --git a/rust/crates/truapi-host-cli/src/frame_server.rs b/rust/crates/truapi-host-cli/src/frame_server.rs new file mode 100644 index 00000000..131f7b9b --- /dev/null +++ b/rust/crates/truapi-host-cli/src/frame_server.rs @@ -0,0 +1,130 @@ +//! Product-frame WebSocket bridge for the pairing host. +//! +//! Each WebSocket connection is one product: inbound binary frames are pushed +//! into a [`ProductRuntime`] and its outgoing frames are written back as +//! binary messages. One binary WS message carries exactly one SCALE +//! `ProtocolMessage`, matching the browser transport's framing. + +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use futures_util::{SinkExt, StreamExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::mpsc; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; +use tracing::{debug, info}; +use truapi_server::{ + FrameSink, PairingHostRuntime, ProductContext, ProductRuntime, SigningHostRuntime, +}; + +pub trait ProductRuntimeFactory: Send + Sync + 'static { + fn product_runtime(&self, product: ProductContext, sink: Arc) -> ProductRuntime; +} + +impl ProductRuntimeFactory for PairingHostRuntime { + fn product_runtime(&self, product: ProductContext, sink: Arc) -> ProductRuntime { + PairingHostRuntime::product_runtime(self, product, sink) + } +} + +impl ProductRuntimeFactory for SigningHostRuntime { + fn product_runtime(&self, product: ProductContext, sink: Arc) -> ProductRuntime { + SigningHostRuntime::product_runtime(self, product, sink) + } +} + +/// Frame sink that writes each outgoing protocol frame as one binary message. +struct WsFrameSink { + outbound: mpsc::UnboundedSender, +} + +impl FrameSink for WsFrameSink { + fn emit_frame(&self, frame: Vec) { + let _ = self.outbound.send(Message::Binary(frame)); + } +} + +/// Bind the product-frame listener on `addr`. +pub async fn bind(addr: SocketAddr) -> Result { + TcpListener::bind(addr) + .await + .with_context(|| format!("frame server failed to bind {addr}")) +} + +/// Accept product-frame connections on `listener` for `product_id` until +/// cancelled. +/// +/// The product dispatch future is `!Send` (matching the single-threaded wasm +/// runtime), so connections are driven with `spawn_local`; callers must run +/// this inside a `tokio::task::LocalSet`. The runtime's own subscription work +/// is `Send` and still runs on the multi-thread pool via the tokio spawner. +pub async fn accept_loop( + runtime: Arc, + product_id: String, + listener: TcpListener, +) -> Result<()> { + let bound = listener.local_addr()?; + info!(%bound, %product_id, "product frame server listening"); + loop { + let (stream, peer) = listener.accept().await?; + let runtime = runtime.clone(); + let product_id = product_id.clone(); + tokio::task::spawn_local(async move { + if let Err(err) = serve_connection(runtime, product_id, stream).await { + debug!(%peer, %err, "frame connection ended"); + } + }); + } +} + +async fn serve_connection( + runtime: Arc, + product_id: String, + stream: TcpStream, +) -> Result<()> { + let ws = accept_async(stream).await?; + let (mut write, mut read) = ws.split(); + let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); + + let writer = tokio::spawn(async move { + while let Some(message) = outbound_rx.recv().await { + if write.send(message).await.is_err() { + break; + } + } + }); + + let product = ProductContext::new(product_id) + .map_err(|err| anyhow::anyhow!("invalid product id: {err}"))?; + let sink = Arc::new(WsFrameSink { + outbound: outbound_tx.clone(), + }); + let product_runtime = runtime.product_runtime(product, sink); + + while let Some(message) = read.next().await { + match message { + Ok(Message::Binary(bytes)) => { + if let Err(err) = product_runtime.receive_frame(bytes.to_vec()).await { + debug!(%err, "product runtime rejected frame"); + } + } + Ok(Message::Text(text)) => { + if let Err(err) = product_runtime + .receive_frame(text.as_bytes().to_vec()) + .await + { + debug!(%err, "product runtime rejected text frame"); + } + } + Ok(Message::Close(_)) | Err(_) => break, + Ok(_) => {} + } + } + + product_runtime.dispose(); + drop(outbound_tx); + let _ = writer.await; + Ok(()) +} diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs new file mode 100644 index 00000000..bcc8b465 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -0,0 +1,791 @@ +//! Headless TrUAPI hosts for local end-to-end testing. +//! +//! Two roles, one binary, pairing over the real People-chain statement store: +//! - `pairing-host`: a seedless host that presents a pairing deeplink and +//! serves product frames over WebSocket (the surface a product/test driver +//! talks to). +//! - `signing-host`: a wallet-local host that answers a pairing deeplink and +//! auto-signs, replacing the external signing-bot in e2e. +//! +//! Plus `alloc-check`, a diagnostic for on-chain statement-store allowance. + +mod accounts; +mod alloc; +mod attestation; +mod chain; +mod frame_server; +mod network; +mod platform; +mod script_runner; + +use std::future::Future; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::process::ExitStatus; +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use clap::{Args, Parser, Subcommand}; +use futures::future::BoxFuture; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use truapi_platform::{HostInfo, PlatformInfo}; +use truapi_server::subscription::Spawner; +use truapi_server::{PairingHostConfig, PairingHostRuntime, SigningHostConfig, SigningHostRuntime}; + +use crate::accounts::{ResolveSignerConfig, ResolvedSigner}; +use crate::network::{Network, NetworkConfig}; +use crate::platform::{ApprovalPolicy, CliPlatform}; + +/// Default product served by the pairing host's frame endpoint. Product ids +/// must be a `.dot` name or a `localhost` identifier (host-spec product id). +const DEFAULT_PRODUCT_ID: &str = "headless-playground.dot"; +/// Default product-frame address for the pairing host. +const DEFAULT_PAIRING_FRAME_LISTEN: &str = "127.0.0.1:9955"; +/// Default product-frame address for the signing host. +const DEFAULT_SIGNING_FRAME_LISTEN: &str = "127.0.0.1:9956"; +/// Deeplink scheme advertised by the pairing host. +const DEEPLINK_SCHEME: &str = "polkadotapp"; + +#[derive(Parser)] +#[command(name = "truapi-host", about = "Headless TrUAPI hosts for e2e testing")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Run a seedless pairing host for product scripts or interactive pairing. + /// + /// With `--script`, exits with the script's status. Without it, stays in an + /// interactive shell where scripts can be run repeatedly. + PairingHost(PairingHostArgs), + /// Run a wallet-local signing host for scripts or pairing deeplinks. + /// + /// Owns signer identity, auto-manages accounts when no mnemonic/account is + /// specified, and can accept pairing deeplinks. With `--script`, exits with + /// the script's status; otherwise stays interactive. + SigningHost(SigningHostArgs), + /// Probe the People chain for a mnemonic's registered identity/username. + IdentityCheck { + /// BIP-39 mnemonic to probe. + #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] + mnemonic: String, + /// Network preset to probe. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + }, + /// Check (and optionally submit) a statement-store allowance registration + /// against the real People chain: ring membership, the chosen slot, and + /// (with `--submit`) the `set_statement_store_account` extrinsic. + AllocCheck { + /// BIP-39 mnemonic proving LitePeople ring membership. + #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] + mnemonic: String, + /// Network preset to use for People-chain RPC. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + /// Target account (hex, 32 bytes) to grant allowance to. Defaults to + /// all-zero (read-only slot scan only). + #[arg(long)] + target: Option, + /// How many rings back from the current index to scan for our member. + #[arg(long, default_value_t = 8)] + lookback: u32, + /// Submit the extrinsic instead of only checking membership + slot. + #[arg(long)] + submit: bool, + }, +} + +#[derive(Args)] +struct PairingHostArgs { + /// Product script to run (JS/TS). If omitted, start an interactive shell. + #[arg(long)] + script: Option, + /// Product id the host serves; scopes storage and product accounts. + #[arg(long = "product-id", default_value = DEFAULT_PRODUCT_ID)] + product_id: String, + /// Address to serve product frames on. + #[arg(long, default_value = DEFAULT_PAIRING_FRAME_LISTEN)] + frame_listen: SocketAddr, + /// Root directory for CLI-managed host state. + #[arg(long = "base-path", env = "TRUAPI_HOST_BASE_PATH")] + base_path: Option, + /// Network preset that supplies all RPC/backend/genesis config. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + /// Approve every confirmation without prompting on the CLI. + #[arg(long)] + auto_accept: bool, +} + +#[derive(Args)] +struct SigningHostArgs { + /// Product script to run (JS/TS). If omitted, start an interactive shell. + #[arg(long)] + script: Option, + /// Product id used by scripts and product-scoped operations. + #[arg(long = "product-id", default_value = DEFAULT_PRODUCT_ID)] + product_id: String, + /// Pairing deeplink to answer. If omitted, no pairing is accepted + /// automatically; interactive mode lets you paste one later. + #[arg(long)] + deeplink: Option, + /// BIP-39 mnemonic for the wallet root. If omitted, the + /// `HOST_CLI_SIGNER_MNEMONIC` env var is used when set. Any mnemonic + /// bypasses account auto-management. + #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] + mnemonic: Option, + /// Named stored account to use. Omit this and `--mnemonic` to auto-select + /// or create a usable account. + #[arg(long)] + account: Option, + /// Prefix for newly-created lite usernames in auto-account mode. + #[arg(long = "lite-username-prefix")] + lite_username_prefix: Option, + /// Root directory for CLI-managed account and host state. + #[arg(long = "base-path", env = "TRUAPI_HOST_BASE_PATH")] + base_path: Option, + /// Network preset that supplies all RPC/backend/genesis config. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + /// Address to serve product frames on when running scripts. + #[arg(long, default_value = DEFAULT_SIGNING_FRAME_LISTEN)] + frame_listen: SocketAddr, + /// Approve every confirmation without prompting on the CLI. + #[arg(long)] + auto_accept: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Install a rustls crypto provider so `wss://` chain connections work; + // rustls 0.23 panics without a process-level default provider. + let _ = rustls::crypto::ring::default_provider().install_default(); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_writer(std::io::stderr) + .init(); + + match Cli::parse().command { + Command::PairingHost(args) => run_pairing_host(args).await, + Command::SigningHost(args) => run_signing_host(args).await, + Command::IdentityCheck { mnemonic, network } => { + let entropy = bip39::Mnemonic::parse(mnemonic.trim()) + .context("invalid BIP-39 mnemonic")? + .to_entropy(); + attestation::check_identity(network.config().people_ws, &entropy).await + } + Command::AllocCheck { + mnemonic, + network, + target, + lookback, + submit, + } => run_alloc_check(mnemonic, network.config(), target, lookback, submit).await, + } +} + +/// Check statement-store allowance for a mnemonic: ring membership, the chosen +/// slot, and (with `submit`) the `set_statement_store_account` extrinsic. +async fn run_alloc_check( + mnemonic: String, + network: NetworkConfig, + target: Option, + lookback: u32, + submit: bool, +) -> Result<()> { + let entropy = bip39::Mnemonic::parse(mnemonic.trim()) + .context("invalid BIP-39 mnemonic")? + .to_entropy(); + let bandersnatch = alloc::bandersnatch_entropy(&entropy); + + if submit && target.is_none() { + bail!("--target is required with --submit; the all-zero default is read-only"); + } + + let target = match target { + Some(hex_str) => { + let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(&hex_str)) + .context("invalid --target hex")?; + <[u8; 32]>::try_from(bytes.as_slice()) + .map_err(|_| anyhow::anyhow!("--target must be 32 bytes"))? + } + None => [0u8; 32], + }; + + let rpc = alloc::rpc::RpcClient::connect(network.people_ws).await?; + let metadata = alloc::fetch_metadata(&rpc) + .await + .map_err(anyhow::Error::msg)?; + let chain_state = alloc::fetch_chain_state(&rpc) + .await + .map_err(anyhow::Error::msg)?; + println!( + "chain: specVersion={} txVersion={} genesis=0x{}", + chain_state.spec_version, + chain_state.transaction_version, + hex::encode(chain_state.genesis_hash), + ); + + let member = alloc::proof::member_key(bandersnatch); + println!("bandersnatch member=0x{}", hex::encode(member)); + let current_ring = alloc::ring::read_current_ring_index(&rpc) + .await + .map_err(anyhow::Error::msg)?; + println!("current ring index={current_ring}"); + let ring = alloc::find_including_ring(&rpc, &metadata, bandersnatch, lookback) + .await + .map_err(anyhow::Error::msg)?; + match &ring { + Some(r) => println!( + "member INCLUDED in ring_index={} exponent={} included_members={}", + r.ring_index, + r.exponent, + r.members.len(), + ), + None => println!("member NOT in the last {lookback} rings (onboarding pending)"), + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock before UNIX epoch")? + .as_secs(); + let period = alloc::slot::current_period(now); + println!("period={period} target=0x{}", hex::encode(target)); + + match alloc::slot::scan_slot_excluding(&rpc, &metadata, bandersnatch, period, &target, &[]) + .await + { + Ok(alloc::slot::SlotSelection::Free(seq)) => println!("slot scan: free seq={seq}"), + Ok(alloc::slot::SlotSelection::AlreadyAllocated(seq)) => { + println!("slot scan: target already allocated at seq={seq}") + } + Err(err) => println!("slot scan: {err}"), + } + + if submit { + let ring = ring.ok_or_else(|| anyhow::anyhow!("cannot submit: member not in any ring"))?; + match alloc::register_statement_account( + &rpc, + &metadata, + &chain_state, + bandersnatch, + &target, + period, + &ring, + ) + .await + { + Ok(alloc::RegistrationOutcome::Registered { + block_hash, + seq, + ring_index, + }) => println!("REGISTERED seq={seq} ring_index={ring_index} block={block_hash}"), + Ok(alloc::RegistrationOutcome::AlreadyAllocated { seq }) => { + println!("already allocated at seq={seq}") + } + Err(err) => bail!("registration failed: {err}"), + } + } + + Ok(()) +} + +/// Map the `--auto-accept` flag to an approval policy: auto-accept, or prompt +/// each confirmation on the CLI. +fn approval_policy(auto_accept: bool) -> ApprovalPolicy { + if auto_accept { + ApprovalPolicy::AutoAccept + } else { + ApprovalPolicy::Prompt + } +} + +/// Spawner that runs runtime futures on the tokio runtime, so their WebSocket +/// connects and timers have a reactor. +fn tokio_spawner() -> Spawner { + Arc::new(|fut: BoxFuture<'static, ()>| { + tokio::spawn(fut); + }) +} + +fn host_info(name: &str) -> HostInfo { + HostInfo { + name: name.to_string(), + icon: None, + version: Some(env!("CARGO_PKG_VERSION").to_string()), + } +} + +fn platform_info() -> PlatformInfo { + PlatformInfo { + kind: Some("cli".to_string()), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + } +} + +async fn run_pairing_host(args: PairingHostArgs) -> Result<()> { + let network = args.network.config(); + let base_path = args.base_path.unwrap_or_else(default_base_path); + let product_id = args.product_id; + let platform = CliPlatform::new( + network.people_ws, + network.live_chain_endpoints, + Some(role_state_path(&base_path, network, "pairing-host")), + approval_policy(args.auto_accept), + ); + // SSO and identity both run over the real People chain, so usernames always + // resolve from `Resources.Consumers` (host-spec G). + let config = PairingHostConfig::new( + host_info("Headless Pairing Host"), + platform_info(), + network.people_genesis, + network.bulletin_genesis, + DEEPLINK_SCHEME.to_string(), + ) + .context("invalid pairing host config")? + .with_identity_chain_genesis_hash(network.people_genesis); + let runtime = Arc::new(PairingHostRuntime::new(platform, config, tokio_spawner())); + + let listener = frame_server::bind(args.frame_listen).await?; + let frame_url = format!("ws://{}", listener.local_addr()?); + println!("FRAMES_LISTENING {frame_url}"); + let runtime: Arc = runtime; + + if let Some(script) = args.script { + let script_product_id = product_id.clone(); + let script_frame_url = frame_url.clone(); + let status = with_frame_server(runtime, product_id, listener, async move { + script_runner::run(&script_frame_url, &script_product_id, &script).await + }) + .await?; + std::process::exit(status.code().unwrap_or(1)); + } + + with_frame_server(runtime, product_id.clone(), listener, async move { + pairing_interactive_loop(frame_url, product_id).await + }) + .await +} + +async fn run_signing_host(args: SigningHostArgs) -> Result<()> { + validate_signing_args(&args)?; + let network = args.network.config(); + let base_path = args.base_path.clone().unwrap_or_else(default_base_path); + let mut session = start_signing_host(&args, base_path, network).await?; + let listener = frame_server::bind(args.frame_listen).await?; + let frame_url = format!("ws://{}", listener.local_addr()?); + println!("FRAMES_LISTENING {frame_url}"); + let runtime_for_frames: Arc = session.runtime.clone(); + + if let Some(script) = args.script { + let product_id = args.product_id.clone(); + let script_product_id = product_id.clone(); + let script_frame_url = frame_url.clone(); + let initial_deeplink = args.deeplink.clone(); + let status = with_frame_server(runtime_for_frames, product_id, listener, async move { + let mut responder = None; + if let Some(deeplink) = initial_deeplink { + prepare_pairing_response(&mut session, &deeplink).await?; + let runtime = session.runtime.clone(); + responder = Some(tokio::spawn(async move { + match runtime.respond_to_pairing(&deeplink).await { + Ok(exit) => println!("SIGNING_HOST_EXIT {exit:?}"), + Err(err) => eprintln!("SIGNING_HOST_ERROR {}", err.reason), + } + })); + } + ensure_signer(&mut session).await?; + let status = script_runner::run(&script_frame_url, &script_product_id, &script).await?; + if let Some(responder) = responder { + responder.abort(); + } + Ok::(status) + }) + .await?; + std::process::exit(status.code().unwrap_or(1)); + } + + let product_id = args.product_id.clone(); + let initial_deeplink = args.deeplink.clone(); + with_frame_server( + runtime_for_frames, + product_id.clone(), + listener, + async move { + if let Some(deeplink) = initial_deeplink { + respond_to_deeplink(&mut session, deeplink).await?; + } + signing_interactive_loop(&mut session, frame_url, product_id).await + }, + ) + .await +} + +struct SigningHostSession { + runtime: Arc, + signer: Option, + base_path: PathBuf, + network: NetworkConfig, + mnemonic: Option, + account: Option, + lite_username_prefix: Option, +} + +async fn start_signing_host( + args: &SigningHostArgs, + base_path: PathBuf, + network: NetworkConfig, +) -> Result { + let platform = CliPlatform::new( + network.people_ws, + network.live_chain_endpoints, + Some(role_state_path(&base_path, network, "signing-host")), + approval_policy(args.auto_accept), + ); + let config = SigningHostConfig::new( + host_info("Headless Signing Host"), + platform_info(), + network.people_genesis, + network.bulletin_genesis, + ) + .context("invalid signing host config")?; + let runtime = Arc::new(SigningHostRuntime::new(platform, config, tokio_spawner())); + + Ok(SigningHostSession { + runtime, + signer: None, + base_path, + network, + mnemonic: normalized(args.mnemonic.clone()), + account: normalized(args.account.clone()), + lite_username_prefix: normalized(args.lite_username_prefix.clone()), + }) +} + +fn validate_signing_args(args: &SigningHostArgs) -> Result<()> { + let mnemonic = normalized(args.mnemonic.clone()); + let account = normalized(args.account.clone()); + let prefix = normalized(args.lite_username_prefix.clone()); + if mnemonic.is_some() && account.is_some() { + bail!("--account cannot be used when --mnemonic or HOST_CLI_SIGNER_MNEMONIC is set"); + } + if mnemonic.is_some() && prefix.is_some() { + bail!( + "--lite-username-prefix cannot be used when --mnemonic or HOST_CLI_SIGNER_MNEMONIC is set" + ); + } + if account.is_some() && prefix.is_some() { + bail!("--lite-username-prefix only applies when --account is omitted"); + } + Ok(()) +} + +fn normalized(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }) +} + +async fn with_frame_server( + runtime: Arc, + product_id: String, + listener: tokio::net::TcpListener, + body: Fut, +) -> Result +where + Fut: Future>, +{ + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + let server = + tokio::task::spawn_local(frame_server::accept_loop(runtime, product_id, listener)); + let result = body.await; + server.abort(); + result + }) + .await +} + +async fn ensure_signer(session: &mut SigningHostSession) -> Result<()> { + if session.signer.is_some() { + return Ok(()); + } + session.signer = Some( + accounts::resolve_signer(ResolveSignerConfig { + base_path: &session.base_path, + network: session.network, + mnemonic: session.mnemonic.clone(), + account: session.account.clone(), + lite_username_prefix: session.lite_username_prefix.clone(), + }) + .await?, + ); + activate_current_signer(session).await +} + +async fn activate_current_signer(session: &mut SigningHostSession) -> Result<()> { + let signer = session + .signer + .as_ref() + .context("signer has not been resolved")?; + session + .runtime + .activate_local_session_with_identity(signer.entropy.clone(), signer.lite_username.clone()) + .await + .map_err(|err| anyhow::anyhow!("failed to activate local session: {}", err.reason))?; + println!("SIGNING_HOST_READY"); + Ok(()) +} + +async fn prepare_pairing_response(session: &mut SigningHostSession, deeplink: &str) -> Result<()> { + let mut attempts = 0usize; + loop { + ensure_signer(session).await?; + let (entropy, auto_managed, account_name) = { + let signer = session + .signer + .as_ref() + .context("signer has not been resolved")?; + ( + signer.entropy.clone(), + signer.auto_managed, + signer.account_name.clone(), + ) + }; + match register_pairing_allowances(session.network.people_ws, &entropy, deeplink).await { + Ok(()) => return Ok(()), + Err(err) if auto_managed && is_statement_slot_exhaustion(&err) => { + attempts += 1; + if attempts > 8 { + return Err(err); + } + if let Some(name) = &account_name { + let period = accounts::current_statement_period()?; + accounts::mark_account_exhausted( + &session.base_path, + session.network.id, + name, + period, + )?; + println!("SIGNING_HOST_ACCOUNT_EXHAUSTED {name} period={period}"); + } + session.signer = Some( + accounts::resolve_signer(ResolveSignerConfig { + base_path: &session.base_path, + network: session.network, + mnemonic: None, + account: None, + lite_username_prefix: session.lite_username_prefix.clone(), + }) + .await?, + ); + activate_current_signer(session).await?; + } + Err(err) => return Err(err), + } + } +} + +fn is_statement_slot_exhaustion(err: &anyhow::Error) -> bool { + err.to_string().contains("no free StatementStore slot") +} + +async fn respond_to_deeplink(session: &mut SigningHostSession, deeplink: String) -> Result<()> { + prepare_pairing_response(session, &deeplink).await?; + let exit = session + .runtime + .respond_to_pairing(&deeplink) + .await + .map_err(|err| anyhow::anyhow!("pairing failed: {}", err.reason))?; + println!("SIGNING_HOST_EXIT {exit:?}"); + Ok(()) +} + +/// Grant on-chain statement-store allowance to the two accounts that submit +/// statements during pairing: the signing host's own `//wallet//sso` account +/// and the pairing host's per-pairing device key (from the deeplink). Proves +/// the signing account's LitePeople ring membership once and reuses it. +async fn register_pairing_allowances( + statement_store_url: &str, + entropy: &[u8], + deeplink: &str, +) -> Result<()> { + use truapi_server::host_logic::product_account::derive_sr25519_hard_path; + use truapi_server::host_logic::sso::pairing::{ + VersionedHandshakeProposal, decode_pairing_deeplink, + }; + + let wallet_sso = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|e| anyhow::anyhow!("//wallet//sso derivation failed: {e}"))? + .public + .to_bytes(); + let VersionedHandshakeProposal::V2(proposal) = + decode_pairing_deeplink(deeplink).map_err(anyhow::Error::msg)?; + let device = proposal.device.statement_account_id; + + let bandersnatch = alloc::bandersnatch_entropy(entropy); + let rpc = alloc::rpc::RpcClient::connect(statement_store_url).await?; + let metadata = alloc::fetch_metadata(&rpc) + .await + .map_err(anyhow::Error::msg)?; + let chain_state = alloc::fetch_chain_state(&rpc) + .await + .map_err(anyhow::Error::msg)?; + + // The signing account may be in an old ring, so scan back to genesis. + let current = alloc::ring::read_current_ring_index(&rpc) + .await + .map_err(anyhow::Error::msg)?; + let ring = alloc::find_including_ring(&rpc, &metadata, bandersnatch, current) + .await + .map_err(anyhow::Error::msg)? + .ok_or_else(|| { + anyhow::anyhow!( + "signing account is not a LitePeople ring member; cannot grant allowance" + ) + })?; + println!( + "SIGNING_HOST_RING ring_index={} members={}", + ring.ring_index, + ring.members.len() + ); + + let period = alloc::slot::current_period( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock before UNIX epoch")? + .as_secs(), + ); + + for (label, target) in [("wallet-sso", wallet_sso), ("device", device)] { + println!("SIGNING_HOST_ALLOWANCE {label} checking"); + let outcome = alloc::register_statement_account( + &rpc, + &metadata, + &chain_state, + bandersnatch, + &target, + period, + &ring, + ) + .await + .map_err(|e| anyhow::anyhow!("allowance registration for {label} failed: {e}"))?; + match outcome { + alloc::RegistrationOutcome::Registered { + block_hash, seq, .. + } => println!("SIGNING_HOST_ALLOWANCE {label} seq={seq} block={block_hash}"), + alloc::RegistrationOutcome::AlreadyAllocated { seq } => { + println!("SIGNING_HOST_ALLOWANCE {label} already-allocated seq={seq}") + } + } + } + Ok(()) +} + +async fn pairing_interactive_loop(frame_url: String, product_id: String) -> Result<()> { + println!("PAIRING_HOST_INTERACTIVE commands: script , quit"); + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + loop { + print_prompt("pairing-host> ").await?; + let Some(line) = lines.next_line().await? else { + return Ok(()); + }; + let line = line.trim(); + if line.is_empty() { + continue; + } + if is_quit(line) { + return Ok(()); + } + let Some(script) = script_command(line) else { + println!("unknown command; use: script , quit"); + continue; + }; + let status = script_runner::run(&frame_url, &product_id, &script).await?; + println!("SCRIPT_EXIT {}", status.code().unwrap_or(1)); + } +} + +async fn signing_interactive_loop( + session: &mut SigningHostSession, + frame_url: String, + product_id: String, +) -> Result<()> { + println!("SIGNING_HOST_INTERACTIVE commands: deeplink , script , quit"); + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + loop { + print_prompt("signing-host> ").await?; + let Some(line) = lines.next_line().await? else { + return Ok(()); + }; + let line = line.trim(); + if line.is_empty() { + continue; + } + if is_quit(line) { + return Ok(()); + } + if let Some(deeplink) = deeplink_command(line) { + respond_to_deeplink(session, deeplink).await?; + continue; + } + if let Some(script) = script_command(line) { + ensure_signer(session).await?; + let status = script_runner::run(&frame_url, &product_id, &script).await?; + println!("SCRIPT_EXIT {}", status.code().unwrap_or(1)); + continue; + } + println!("unknown command; use: deeplink , script , quit"); + } +} + +async fn print_prompt(prompt: &str) -> Result<()> { + let mut stdout = tokio::io::stdout(); + stdout.write_all(prompt.as_bytes()).await?; + stdout.flush().await?; + Ok(()) +} + +fn is_quit(line: &str) -> bool { + matches!(line, "quit" | "exit" | "q") +} + +fn script_command(line: &str) -> Option { + line.strip_prefix("script ") + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) +} + +fn deeplink_command(line: &str) -> Option { + if line.starts_with("polkadotapp://pair?") { + return Some(line.to_string()); + } + line.strip_prefix("deeplink ") + .map(str::trim) + .filter(|deeplink| !deeplink.is_empty()) + .map(str::to_string) +} + +fn default_base_path() -> PathBuf { + if let Some(path) = std::env::var_os("XDG_STATE_HOME") { + return PathBuf::from(path).join("truapi-host"); + } + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home).join(".local/state/truapi-host"); + } + PathBuf::from(".truapi-host") +} + +fn role_state_path(base_path: &std::path::Path, network: NetworkConfig, role: &str) -> PathBuf { + base_path.join(network.id).join(role) +} diff --git a/rust/crates/truapi-host-cli/src/network.rs b/rust/crates/truapi-host-cli/src/network.rs new file mode 100644 index 00000000..c8519b6b --- /dev/null +++ b/rust/crates/truapi-host-cli/src/network.rs @@ -0,0 +1,89 @@ +use clap::ValueEnum; + +/// Supported live network presets for the headless hosts. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] +pub enum Network { + #[value(name = "paseo-next-v2")] + #[default] + PaseoNextV2, +} + +impl Network { + pub fn config(self) -> NetworkConfig { + match self { + Self::PaseoNextV2 => NetworkConfig { + id: "paseo-next-v2", + identity_backend_base: "https://identity-backend-next.parity-testnet.parity.io/api/v1", + people_ws: "wss://paseo-people-next-system-rpc.polkadot.io", + bulletin_ws: "wss://paseo-bulletin-next-rpc.polkadot.io", + people_genesis: hex_literal_genesis( + "c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5", + ), + bulletin_genesis: hex_literal_genesis( + "8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22", + ), + live_chain_endpoints: PASEO_NEXT_V2_CHAIN_ENDPOINTS, + }, + } + } +} + +const PASEO_NEXT_V2_CHAIN_ENDPOINTS: &[ChainEndpoint] = &[ + ChainEndpoint { + genesis: hex_literal_genesis( + "bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f", + ), + ws: "wss://paseo-asset-hub-next-rpc.polkadot.io", + }, + ChainEndpoint { + genesis: hex_literal_genesis( + "c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5", + ), + ws: "wss://paseo-people-next-system-rpc.polkadot.io", + }, + ChainEndpoint { + genesis: hex_literal_genesis( + "8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22", + ), + ws: "wss://paseo-bulletin-next-rpc.polkadot.io", + }, +]; + +/// Resolved RPC/backend/genesis values for one network preset. +#[derive(Debug, Clone, Copy)] +pub struct NetworkConfig { + pub id: &'static str, + pub identity_backend_base: &'static str, + pub people_ws: &'static str, + #[allow(dead_code)] + pub bulletin_ws: &'static str, + pub people_genesis: [u8; 32], + pub bulletin_genesis: [u8; 32], + pub live_chain_endpoints: &'static [ChainEndpoint], +} + +#[derive(Debug, Clone, Copy)] +pub struct ChainEndpoint { + pub genesis: [u8; 32], + pub ws: &'static str, +} + +/// Decode a 64-char hex genesis at compile time. +const fn hex_literal_genesis(hex: &str) -> [u8; 32] { + let bytes = hex.as_bytes(); + let mut out = [0u8; 32]; + let mut i = 0; + while i < 32 { + out[i] = hex_nibble(bytes[i * 2]) << 4 | hex_nibble(bytes[i * 2 + 1]); + i += 1; + } + out +} + +const fn hex_nibble(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + _ => panic!("invalid hex digit in genesis literal"), + } +} diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs new file mode 100644 index 00000000..d1a87f0a --- /dev/null +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -0,0 +1,383 @@ +//! `Platform` implementation for the headless hosts. +//! +//! In-memory product and core storage, a WebSocket chain provider pointed at +//! the real People-chain statement store, and a [`UserConfirmation`] that +//! either auto-accepts or prompts on the CLI (the web/iOS "sign?" modal). +//! Auth-state transitions are published on a channel so the CLI can print the +//! pairing deeplink and observe connection status. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use futures::stream::{self, BoxStream}; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::Mutex as AsyncMutex; +use truapi::v01; +use truapi_platform::{ + AuthState, ChainProvider, CoreStorage, CoreStorageKey, Features, JsonRpcConnection, Navigation, + Notifications, Permissions, PreimageHost, ProductStorage, ThemeHost, UserConfirmation, + UserConfirmationReview, +}; + +use crate::chain::WsChainProvider; + +/// How the host answers confirmation prompts (the web/iOS "sign?" modals). +#[derive(Clone, Copy)] +pub enum ApprovalPolicy { + /// Approve every sensitive action without prompting (`--auto-accept`). + AutoAccept, + /// Prompt on the CLI (y/n) for every sensitive action. + Prompt, +} + +/// Headless-host platform shared by both roles. +pub struct CliPlatform { + chain: WsChainProvider, + product_storage: Mutex>>, + core_storage: Mutex, Vec>>, + product_storage_path: Option, + core_storage_path: Option, + preimages: Mutex, Vec>>, + approval: ApprovalPolicy, + /// Serializes interactive CLI prompts so concurrent confirmations don't + /// interleave on stdin. + prompt_lock: AsyncMutex<()>, +} + +impl CliPlatform { + /// Build a platform whose chain provider connects to the network's People + /// chain and whose optional state directory backs product/core storage. + pub fn new( + statement_store_url: impl Into, + live_chain_endpoints: &[crate::network::ChainEndpoint], + storage_dir: Option, + approval: ApprovalPolicy, + ) -> Arc { + let (product_storage_path, core_storage_path) = storage_dir + .as_ref() + .map(|dir| { + let _ = fs::create_dir_all(dir); + ( + Some(dir.join("product-storage.json")), + Some(dir.join("core-storage.json")), + ) + }) + .unwrap_or((None, None)); + let product_storage = product_storage_path + .as_deref() + .map(load_string_map) + .unwrap_or_default(); + let core_storage = core_storage_path + .as_deref() + .map(load_hex_key_map) + .unwrap_or_default(); + + Arc::new(Self { + chain: WsChainProvider::new(statement_store_url, live_chain_endpoints), + product_storage: Mutex::new(product_storage), + core_storage: Mutex::new(core_storage), + product_storage_path, + core_storage_path, + preimages: Mutex::new(HashMap::new()), + approval, + prompt_lock: AsyncMutex::new(()), + }) + } + + fn core_key(key: &CoreStorageKey) -> Vec { + use parity_scale_codec::Encode; + key.encode() + } + + fn persist_product_storage(&self) -> Result<(), String> { + let Some(path) = &self.product_storage_path else { + return Ok(()); + }; + let storage = self + .product_storage + .lock() + .expect("product storage mutex poisoned"); + save_string_map(path, &storage) + } + + fn persist_core_storage(&self) -> Result<(), String> { + let Some(path) = &self.core_storage_path else { + return Ok(()); + }; + let storage = self + .core_storage + .lock() + .expect("core storage mutex poisoned"); + save_hex_key_map(path, &storage) + } + + /// Resolve a confirmation: auto-accept, or prompt y/n on the CLI. + async fn decide(&self, action: &str, detail: String) -> bool { + match self.approval { + ApprovalPolicy::AutoAccept => { + eprintln!("[auto-accept] {action}: {detail}"); + true + } + ApprovalPolicy::Prompt => { + let _guard = self.prompt_lock.lock().await; + prompt_yes_no(action, &detail).await + } + } + } +} + +/// Print a confirmation and read a y/n answer from the CLI (default: no). +async fn prompt_yes_no(action: &str, detail: &str) -> bool { + let mut stdout = tokio::io::stdout(); + let _ = stdout + .write_all( + format!( + "\n\u{2500}\u{2500} confirm: {action} \u{2500}\u{2500}\n{detail}\nApprove? [y/N] " + ) + .as_bytes(), + ) + .await; + let _ = stdout.flush().await; + let mut line = String::new(); + let mut reader = BufReader::new(tokio::io::stdin()); + if reader.read_line(&mut line).await.unwrap_or(0) == 0 { + return false; + } + matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes") +} + +#[async_trait] +impl ProductStorage for CliPlatform { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { + Ok(self + .product_storage + .lock() + .expect("product storage mutex poisoned") + .get(&key) + .cloned()) + } + + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + { + self.product_storage + .lock() + .expect("product storage mutex poisoned") + .insert(key, value); + } + self.persist_product_storage() + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { + { + self.product_storage + .lock() + .expect("product storage mutex poisoned") + .remove(&key); + } + self.persist_product_storage() + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } +} + +#[async_trait] +impl CoreStorage for CliPlatform { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + Ok(self + .core_storage + .lock() + .expect("core storage mutex poisoned") + .get(&Self::core_key(&key)) + .cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + { + self.core_storage + .lock() + .expect("core storage mutex poisoned") + .insert(Self::core_key(&key), value); + } + self.persist_core_storage() + .map_err(|reason| v01::GenericError { reason }) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { + { + self.core_storage + .lock() + .expect("core storage mutex poisoned") + .remove(&Self::core_key(&key)); + } + self.persist_core_storage() + .map_err(|reason| v01::GenericError { reason }) + } +} + +#[async_trait] +impl ChainProvider for CliPlatform { + async fn connect( + &self, + genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + self.chain.connect(genesis_hash).await + } +} + +#[async_trait] +impl Navigation for CliPlatform { + async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { + tracing::info!(%url, "navigate_to"); + Ok(()) + } +} + +#[async_trait] +impl Notifications for CliPlatform { + async fn push_notification( + &self, + _notification: v01::HostPushNotificationRequest, + ) -> Result { + Ok(v01::HostPushNotificationResponse { id: 1 }) + } +} + +#[async_trait] +impl Permissions for CliPlatform { + async fn device_permission( + &self, + request: v01::HostDevicePermissionRequest, + ) -> Result { + let granted = self + .decide("device permission", format!("{request:?}")) + .await; + Ok(v01::HostDevicePermissionResponse { granted }) + } + + async fn remote_permission( + &self, + request: v01::RemotePermissionRequest, + ) -> Result { + let granted = self + .decide("remote permission", format!("{request:?}")) + .await; + Ok(v01::RemotePermissionResponse { granted }) + } +} + +#[async_trait] +impl Features for CliPlatform { + async fn feature_supported( + &self, + _request: v01::HostFeatureSupportedRequest, + ) -> Result { + Ok(v01::HostFeatureSupportedResponse { supported: true }) + } +} + +impl truapi_platform::AuthPresenter for CliPlatform { + fn auth_state_changed(&self, state: AuthState) { + // Machine-readable lines for orchestrators to observe pairing. + match &state { + AuthState::Pairing { deeplink } => println!("PAIRING_DEEPLINK {deeplink}"), + AuthState::Connected(_) => println!("PAIRING_CONNECTED"), + AuthState::Disconnected => println!("PAIRING_DISCONNECTED"), + AuthState::LoginFailed { reason } => println!("PAIRING_FAILED {reason}"), + } + } +} + +#[async_trait] +impl UserConfirmation for CliPlatform { + async fn confirm_user_action( + &self, + review: UserConfirmationReview, + ) -> Result { + Ok(self.decide("sign request", format!("{review:?}")).await) + } +} + +impl ThemeHost for CliPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + Box::pin(stream::once(async { Ok(v01::ThemeVariant::Dark) })) + } +} + +impl PreimageHost for CliPlatform { + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + let value = self + .preimages + .lock() + .expect("preimage mutex poisoned") + .get(&key) + .cloned(); + Box::pin(stream::once(async move { Ok(value) })) + } +} + +#[derive(Serialize, Deserialize)] +struct JsonMap { + values: HashMap, +} + +fn load_string_map(path: &Path) -> HashMap> { + let Ok(text) = fs::read_to_string(path) else { + return HashMap::new(); + }; + serde_json::from_str::(&text) + .ok() + .map(|json| { + json.values + .into_iter() + .filter_map(|(key, value)| hex::decode(value).ok().map(|bytes| (key, bytes))) + .collect() + }) + .unwrap_or_default() +} + +fn save_string_map(path: &Path, values: &HashMap>) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| format!("create storage dir: {err}"))?; + } + let json = JsonMap { + values: values + .iter() + .map(|(key, value)| (key.clone(), hex::encode(value))) + .collect(), + }; + let text = serde_json::to_string_pretty(&json).map_err(|err| err.to_string())?; + fs::write(path, text).map_err(|err| format!("write {}: {err}", path.display())) +} + +fn load_hex_key_map(path: &Path) -> HashMap, Vec> { + load_string_map(path) + .into_iter() + .filter_map(|(key, value)| hex::decode(key).ok().map(|decoded| (decoded, value))) + .collect() +} + +fn save_hex_key_map(path: &Path, values: &HashMap, Vec>) -> Result<(), String> { + let keyed: HashMap> = values + .iter() + .map(|(key, value)| (hex::encode(key), value.clone())) + .collect(); + save_string_map(path, &keyed) +} diff --git a/rust/crates/truapi-host-cli/src/script_runner.rs b/rust/crates/truapi-host-cli/src/script_runner.rs new file mode 100644 index 00000000..58d96061 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/script_runner.rs @@ -0,0 +1,49 @@ +//! Runs a user host-script under `bun`, driving a host through the injected +//! `truapi` global. +//! +//! The Rust CLI owns the flow: it starts the host, then spawns `js/runner.ts` +//! (which connects the `@parity/truapi` client to the host and evaluates the +//! user script). The child's exit status becomes the host command's status, so +//! `truapi-host pairing-host --script foo.ts` *is* the test — there is no +//! separate bun orchestrator. + +use std::path::{Path, PathBuf}; +use std::process::ExitStatus; + +use anyhow::{Context, Result}; +use tokio::process::Command; + +/// Locate `js/runner.ts`, shipped alongside the crate. +/// +/// Overridable with `TRUAPI_HOST_RUNNER` for packaged/relocated builds. +fn runner_path() -> PathBuf { + if let Ok(path) = std::env::var("TRUAPI_HOST_RUNNER") { + return PathBuf::from(path); + } + Path::new(env!("CARGO_MANIFEST_DIR")).join("js/runner.ts") +} + +/// Run `script` against the host serving frames at `frame_url`, as product +/// `product_id`. Inherits stdio so the script's output and any CLI confirmation +/// prompts share the terminal. Returns the child's exit status. +pub async fn run(frame_url: &str, product_id: &str, script: &Path) -> Result { + let runner = runner_path(); + if !runner.exists() { + anyhow::bail!( + "host-script runner not found at {}; set TRUAPI_HOST_RUNNER", + runner.display() + ); + } + let script = script + .canonicalize() + .with_context(|| format!("script not found: {}", script.display()))?; + + Command::new("bun") + .arg(&runner) + .env("TRUAPI_FRAME_URL", frame_url) + .env("TRUAPI_PRODUCT_ID", product_id) + .env("TRUAPI_SCRIPT", &script) + .status() + .await + .context("failed to spawn `bun` for the host script (is bun installed?)") +} diff --git a/rust/crates/truapi-host-cli/tests/fixtures/paseo-next-v2-metadata.scale b/rust/crates/truapi-host-cli/tests/fixtures/paseo-next-v2-metadata.scale new file mode 100644 index 00000000..28a7ac3f Binary files /dev/null and b/rust/crates/truapi-host-cli/tests/fixtures/paseo-next-v2-metadata.scale differ diff --git a/rust/crates/truapi-platform/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..365dd649 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,15 @@ pub struct PairingHostConfig { pub host: HostRuntimeConfig, /// People-chain genesis hash used for statement-store SSO. pub people_chain_genesis_hash: [u8; 32], + /// Optional distinct genesis for People-chain identity (username) lookups. + /// + /// In production this equals `people_chain_genesis_hash` (SSO and identity + /// share the People chain). It can be set separately so a host can run SSO + /// over one transport (e.g. a local relay) while resolving usernames from + /// the real People chain. `None` falls back to `people_chain_genesis_hash`. + pub identity_chain_genesis_hash: Option<[u8; 32]>, + /// Bulletin-chain genesis hash used for in-core preimage submission. + pub bulletin_chain_genesis_hash: [u8; 32], /// Deeplink URI scheme used in pairing QR payloads, without `://`. /// /// Host-spec L.2-L.3 define the `polkadotapp://pair` route and construction @@ -68,6 +74,8 @@ pub struct SigningHostConfig { pub host: HostRuntimeConfig, /// People-chain genesis hash used for statement-store product calls. pub people_chain_genesis_hash: [u8; 32], + /// Bulletin-chain genesis hash used for in-core preimage submission. + pub bulletin_chain_genesis_hash: [u8; 32], } /// Product identity attached to one product-facing TrUAPI connection. @@ -138,6 +146,7 @@ impl PairingHostConfig { host_info: HostInfo, platform_info: PlatformInfo, people_chain_genesis_hash: [u8; 32], + bulletin_chain_genesis_hash: [u8; 32], pairing_deeplink_scheme: String, ) -> Result { require_non_empty("pairing_deeplink_scheme", &pairing_deeplink_scheme)?; @@ -149,10 +158,25 @@ impl PairingHostConfig { let config = Self { host: HostRuntimeConfig::new(host_info, platform_info)?, people_chain_genesis_hash, + identity_chain_genesis_hash: None, + bulletin_chain_genesis_hash, pairing_deeplink_scheme, }; Ok(config) } + + /// Resolve usernames from a People chain distinct from the SSO transport. + pub fn with_identity_chain_genesis_hash(mut self, genesis_hash: [u8; 32]) -> Self { + self.identity_chain_genesis_hash = Some(genesis_hash); + self + } + + /// Genesis used for People-chain identity lookups (falls back to the SSO + /// People-chain genesis when no distinct identity chain is configured). + pub fn identity_lookup_genesis_hash(&self) -> [u8; 32] { + self.identity_chain_genesis_hash + .unwrap_or(self.people_chain_genesis_hash) + } } impl SigningHostConfig { @@ -162,10 +186,12 @@ impl SigningHostConfig { host_info: HostInfo, platform_info: PlatformInfo, people_chain_genesis_hash: [u8; 32], + bulletin_chain_genesis_hash: [u8; 32], ) -> Result { Ok(Self { host: HostRuntimeConfig::new(host_info, platform_info)?, people_chain_genesis_hash, + bulletin_chain_genesis_hash, }) } } @@ -613,9 +639,12 @@ pub trait ThemeHost: Send + Sync { } /// Secret key allocated for Bulletin preimage submission. -#[derive(Clone, derive_more::Debug, PartialEq, Eq)] +/// +/// The core is the sole holder: the secret never crosses the host boundary. +/// Zeroized on drop, and its `Debug` redacts the material. +#[derive(Clone, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop, derive_more::Debug)] pub struct BulletinAllowanceKey { - #[debug("{:?}", "")] + #[debug("\"\"")] secret: [u8; 64], } @@ -630,15 +659,10 @@ impl BulletinAllowanceKey { Ok(Self { secret }) } - /// Raw secret bytes for bridge and storage adapters. - pub fn as_secret_bytes(&self) -> &[u8] { + /// Borrow the raw secret bytes for in-core signing. + pub fn as_secret_bytes(&self) -> &[u8; 64] { &self.secret } - - /// Consume the wrapper and return raw secret bytes. - pub fn into_secret_bytes(self) -> [u8; 64] { - self.secret - } } /// Invalid Bulletin allowance key material. @@ -652,71 +676,11 @@ pub enum BulletinAllowanceKeyError { }, } -/// Bulletin allowance signing capability exposed across the platform boundary. -/// -/// Rust owns the allowance key format and secret material. Host code only gets -/// `public_key + sign(payload)`, enough for PAPI to build and submit the -/// Bulletin transaction without reintroducing allowance key parsing in host code. -type BulletinAllowanceSignFn = - dyn Fn(&[u8]) -> Result<[u8; 64], BulletinAllowanceSignError> + Send + Sync; - -/// Host-facing signer for Bulletin preimage submission. -#[derive(Clone, derive_more::Debug)] -pub struct BulletinAllowanceSigner { - public_key: [u8; 32], - /// Rust-owned signing capability passed to host code without exposing the - /// raw allowance secret. - #[debug("{:?}", "")] - sign: Arc, -} - -impl BulletinAllowanceSigner { - /// Build a signer from a public key and signing function. - pub fn new( - public_key: [u8; 32], - sign: impl Fn(&[u8]) -> Result<[u8; 64], BulletinAllowanceSignError> + Send + Sync + 'static, - ) -> Self { - Self { - public_key, - sign: Arc::new(sign), - } - } - - /// Public key of the allowance account. - pub fn public_key(&self) -> [u8; 32] { - self.public_key - } - - /// Sign a SCALE transaction payload with the allowance account. - pub fn sign(&self, payload: &[u8]) -> Result<[u8; 64], BulletinAllowanceSignError> { - (self.sign)(payload) - } -} - -/// Bulletin allowance signing failed. -#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] -#[display("{reason}")] -pub struct BulletinAllowanceSignError { - /// Human-readable failure reason. - pub reason: String, -} - -/// Host preimage backend. The core owns wire mapping and subscription -/// lifecycle; the host owns the selected backend. +/// Host preimage backend. The core builds, signs, and submits the Bulletin +/// `TransactionStorage.store` transaction itself; the host only owns preimage +/// content retrieval (P2P/IPFS lookup). #[async_trait] pub trait PreimageHost: Send + Sync { - /// Submit the preimage and return its key. - async fn submit_preimage( - &self, - value: Vec, - bulletin_allowance_signer: BulletinAllowanceSigner, - ) -> Result, PreimageSubmitError> { - let _ = (value, bulletin_allowance_signer); - Err(PreimageSubmitError::Unknown { - reason: "submitPreimage callback not provided by host".to_string(), - }) - } - /// Emits current value/miss immediately, then future updates. fn lookup_preimage( &self, diff --git a/rust/crates/truapi-platform/tests/bounds.rs b/rust/crates/truapi-platform/tests/bounds.rs index 5a1733c4..e6973351 100644 --- a/rust/crates/truapi-platform/tests/bounds.rs +++ b/rust/crates/truapi-platform/tests/bounds.rs @@ -96,6 +96,7 @@ fn pairing_config_validation_cases() { }, PlatformInfo::default(), [0xa2; 32], + [0xbb; 32], case.pairing_deeplink_scheme.to_string(), ) .map(|_| ()); diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 93ce86b5..8a30934b 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -8,6 +8,23 @@ license = "MIT" [lib] crate-type = ["rlib", "cdylib"] +[package.metadata.wasm-pack.profile.release] +wasm-opt = [ + "-Oz", + "--enable-bulk-memory", + "--enable-nontrapping-float-to-int", + "--enable-reference-types", + "--enable-sign-ext", + "--strip-debug", + "--strip-dwarf", + "--strip-producers", +] + +[package.metadata.wasm-pack.profile.release.wasm-bindgen] +debug-js-glue = false +demangle-name-section = false +dwarf-debug-info = false + [features] default = [] @@ -18,11 +35,10 @@ unsafe_code = "forbid" truapi = { path = "../truapi" } truapi-platform = { path = "../truapi-platform" } async-trait = "0.1" -derive_more = { version = "2", features = ["display", "error"] } +derive_more = { version = "2", features = ["debug", "display", "error"] } futures = "0.3" -futures-timer = { version = "3", features = ["wasm-bindgen"] } +futures-timer = "3" parity-scale-codec = { version = "3", features = ["derive"] } -primitive-types = { version = "0.13", default-features = false, features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "1" @@ -31,8 +47,8 @@ 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"] } @@ -47,26 +63,29 @@ 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"] } +frame-metadata = { version = "23", default-features = false, features = ["std", "current", "decode"] } +scale-info = { version = "2.11", default-features = false, features = ["decode"] } +# Bandersnatch ring-VRF for signing-host account aliases. The alias path uses +# only the thin VRF (no ring/SRS), and only the native signing host needs it, +# so it is a non-wasm dependency. Pinned to the rev the mobile app references. +verifiable = { git = "https://github.com/paritytech/verifiable", rev = "f65b39df04f2f9a453d78550438b189c96785285", default-features = false, features = ["std", "prover"] } [target.'cfg(target_arch = "wasm32")'.dependencies] +futures-timer = { version = "3", features = ["wasm-bindgen"] } js-sys = "0.3" +subxt = { version = "0.50.2", default-features = false, features = ["web"] } subxt-rpcs = { version = "0.50.1", default-features = false, features = ["web"] } wasm-bindgen = "0.2.118" wasm-bindgen-futures = "0.4" console_error_panic_hook = "0.1" -futures-util = "0.3" -pin-project = "1" send_wrapper = { version = "0.6", features = ["futures"] } web-time = "1" -web-sys = { version = "0.3", features = [ - "BinaryType", - "CloseEvent", - "console", - "Event", - "MessageEvent", - "WebSocket", -] } +web-sys = { version = "0.3", features = ["console"] } + +[dev-dependencies] +scale-info = "2.11" [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 853fc133..c4e2f494 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -7,6 +7,11 @@ //! This module keeps the TrUAPI-facing local follow ids and maps subxt DTOs to //! public v01 [`RemoteChainHeadFollowItem`] values. //! +//! Each connection also lazily caches one genesis-pinned Subxt +//! [`OnlineClient`], its backend driver, and Subxt's per-client metadata cache. +//! Internal services use that client instead of hand-rolling chainHead +//! orchestration where Subxt fits the boundary. +//! //! The chain-side traits return [`RuntimeFailure`], a local classification //! that the [`crate::runtime`] layer maps to [`truapi::CallError`] variants //! (`Unsupported`, `HostFailure`, ...). This avoids leaking json-rpc plumbing @@ -17,22 +22,25 @@ use core::task::{Context, Poll}; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; #[cfg(not(target_arch = "wasm32"))] use std::time::Duration; #[cfg(target_arch = "wasm32")] use web_time::Duration; -use futures::FutureExt; use futures::channel::mpsc; use futures::future::{AbortHandle, Abortable}; use futures::future::{BoxFuture, Shared}; use futures::stream::BoxStream; -use futures::{Stream, StreamExt, pin_mut}; +use futures::{FutureExt, pin_mut}; +use futures::{Stream, StreamExt}; use parity_scale_codec::{Decode, Error as ScaleError, Input}; -use primitive_types::H256; use serde::de::{Deserializer, Error as DeError}; use serde_json::Value; +use subxt::OnlineClient; +use subxt::backend::ChainHeadBackend; +use subxt::config::substrate::{SubstrateConfig, SubstrateConfigBuilder}; +use subxt::utils::H256; use subxt_rpcs::client::RpcClient; use subxt_rpcs::methods::chain_head as subxt_chain; use subxt_rpcs::{ChainHeadRpcMethods, Error as SubxtRpcError, RpcConfig}; @@ -97,6 +105,19 @@ type FollowSetup = Shared>>; /// than each opening a connection and orphaning all but the last insert. type ConnectionSetup = Shared, RuntimeFailure>>>; +/// Shared, single-flight setup of the connection's cached Subxt client. +/// Concurrent first users await one in-flight build rather than each starting +/// (and leaking) a separate backend driver and follow subscription. +type SubxtConnectionSetup = Shared>>; + +/// Cached Subxt client built over one connection's transport. +/// The backend driver is owned by the setup task that created this value. +#[derive(Clone)] +pub(crate) struct SubxtConnection { + /// Client whose chain config pins the host-configured genesis hash. + pub(crate) client: OnlineClient, +} + /// Classification of framework-level chain failures separate from JSON-RPC /// domain errors. Maps cleanly to [`truapi::CallError`] variants at the /// `ProductRuntimeHost` boundary. @@ -137,8 +158,19 @@ impl RuntimeFailure { } } + /// [`Self::unavailable`] carrying the underlying failure text for + /// diagnostics. + pub fn unavailable_with_reason(method: &'static str, reason: impl Into) -> Self { + Self { + kind: RuntimeFailureKind::Unavailable, + method, + reason: Some(reason.into()), + } + } + /// Failure classification. - pub fn kind(&self) -> RuntimeFailureKind { + #[cfg(test)] + fn kind(&self) -> RuntimeFailureKind { self.kind } @@ -156,11 +188,13 @@ impl RuntimeFailure { } } - /// Re-tag this failure under `method`, preserving its kind and reason. + /// Re-tag this failure under `method`, preserving its kind and nesting + /// the original reason (when one exists) for diagnostics. fn reclassify(&self, method: &'static str) -> RuntimeFailure { - match self.kind() { - RuntimeFailureKind::Unavailable => RuntimeFailure::unavailable(method), - RuntimeFailureKind::HostFailure => RuntimeFailure::host_failure(method, self.reason()), + RuntimeFailure { + kind: self.kind, + method, + reason: self.reason.as_ref().map(|_| self.reason()), } } } @@ -221,19 +255,13 @@ impl ChainRuntime { let setup_cancelled = cancelled.clone(); let cleanup_cancelled = cancelled.clone(); + // Every `start_follow` failure path tears the follow state down, + // dropping the stored sender; with this task's clone gone too, the + // local stream ends. Sender drop is the single termination mechanism. let fut = async move { - if runtime - .start_follow( - follow_subscription_id, - request, - Some(tx.clone()), - setup_cancelled, - ) - .await - .is_err() - { - let _ = tx.unbounded_send(FollowSignal::Interrupt); - } + let _ = runtime + .start_follow(follow_subscription_id, request, tx, setup_cancelled) + .await; }; (self.spawner)(fut.boxed()); @@ -244,12 +272,6 @@ impl ChainRuntime { cleanup_runtime.cleanup_follow(&cleanup_genesis_hash, &cleanup_follow_id); })), ) - .filter_map(|signal| async move { - match signal { - FollowSignal::Item(item) => Some(item), - FollowSignal::Interrupt => None, - } - }) .boxed() } @@ -493,6 +515,41 @@ impl ChainRuntime { .map_err(|err| rpc_failure(method, err)) } + /// Genesis-pinned Subxt client for the chain identified by `genesis_hash`. + /// The cached unit is the underlying Subxt connection bundle, not just + /// the cheap client handle. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.online_client"))] + pub(crate) async fn online_client( + &self, + genesis_hash: &[u8], + ) -> Result, RuntimeFailure> { + Ok(self.subxt_connection(genesis_hash).await?.client) + } + + /// Raw JSON-RPC client for the chain identified by `genesis_hash`. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn rpc_client( + &self, + method: &'static str, + genesis_hash: &[u8], + ) -> Result { + Ok(self + .connection_for(method, genesis_hash) + .await? + .rpc_client + .clone()) + } + + async fn subxt_connection( + &self, + genesis_hash: &[u8], + ) -> Result { + let connection = self + .connection_for("subxt_connection", genesis_hash) + .await?; + connection.subxt_connection().await + } + #[instrument(skip_all, fields(runtime.method = "chain_runtime.connection_for", method = method))] async fn connection_for( &self, @@ -524,8 +581,8 @@ impl ChainRuntime { let setup_key = key.clone(); let genesis_hash = genesis_hash.to_owned(); let setup: ConnectionSetup = async move { - let result = provider.connect(genesis_hash).await.map(|rpc| { - let connection = ChainConnection::new(rpc, spawner); + let result = provider.connect(genesis_hash.clone()).await.map(|rpc| { + let connection = ChainConnection::new(rpc, spawner, genesis_hash); connections .lock() .unwrap() @@ -550,7 +607,7 @@ impl ChainRuntime { &self, local_follow_id: String, request: RemoteChainHeadFollowRequest, - sender: Option>, + sender: mpsc::UnboundedSender, cancelled: Arc, ) -> Result<(), RuntimeFailure> { if cancelled.load(Ordering::SeqCst) { @@ -611,32 +668,35 @@ impl ChainRuntime { } } -/// One delivery on the local follow stream. `Interrupt` signals an -/// abnormal close (connection dropped, follow setup failed); it produces no -/// item but ends the stream. -enum FollowSignal { - Item(RemoteChainHeadFollowItem), - Interrupt, -} - struct ChainConnection { rpc_client: HostRpcClient, methods: ChainHeadRpcMethods, spawner: Spawner, + /// Host-configured genesis hash this connection was opened for; pins the + /// cached Subxt bundle's chain config. + genesis_hash: Vec, follows: Mutex>, follow_setups: Mutex>, + /// Cached Subxt bundle setup tagged with its generation, so invalidation + /// (on setup failure or backend-driver exit) can never evict a newer + /// rebuild. + subxt_connection_setup: Mutex>, + subxt_connection_generation: AtomicU64, } impl ChainConnection { - fn new(rpc: Arc, spawner: Spawner) -> Arc { + fn new(rpc: Arc, spawner: Spawner, genesis_hash: Vec) -> Arc { let rpc_client = HostRpcClient::new(rpc, spawner.clone()); let methods = ChainHeadRpcMethods::new(RpcClient::new(rpc_client.clone())); Arc::new(Self { rpc_client, methods, spawner, + genesis_hash, follows: Mutex::new(HashMap::new()), follow_setups: Mutex::new(HashMap::new()), + subxt_connection_setup: Mutex::new(None), + subxt_connection_generation: AtomicU64::new(0), }) } @@ -644,6 +704,98 @@ impl ChainConnection { self.rpc_client.is_closed() } + /// Lazily build (single-flight) and cache the Subxt bundle over this + /// connection's transport. The chain config pins the host-configured + /// genesis hash, so Subxt never reads a provider-echoed one, and the + /// backend follow started here is shared by every user of this connection. + async fn subxt_connection(self: &Arc) -> Result { + let (generation, setup) = { + let mut slot = self.subxt_connection_setup.lock().unwrap(); + if let Some(existing) = slot.clone() { + existing + } else { + let generation = self + .subxt_connection_generation + .fetch_add(1, Ordering::Relaxed) + + 1; + let connection = self.clone(); + let setup: SubxtConnectionSetup = + async move { connection.build_subxt_connection(generation).await } + .boxed() + .shared(); + *slot = Some((generation, setup.clone())); + (generation, setup) + } + }; + let result = setup.await; + // On failure, drop the cached setup so a later call can retry. + if result.is_err() { + self.invalidate_subxt_connection(generation); + } + result + } + + /// Drop the cached Subxt setup if it still belongs to `generation`; + /// stale invalidations (an old driver exiting after a rebuild) are + /// ignored. + fn invalidate_subxt_connection(&self, generation: u64) { + let mut slot = self.subxt_connection_setup.lock().unwrap(); + if slot + .as_ref() + .is_some_and(|(cached, _)| *cached == generation) + { + *slot = None; + } + } + + /// Body of the single-flight Subxt setup: start the chainHead backend, + /// drive it on the connection's spawner, and build the client with the + /// config-pinned genesis hash. + async fn build_subxt_connection( + self: Arc, + generation: u64, + ) -> Result { + const METHOD: &str = "subxt_connection"; + let genesis_hash: [u8; 32] = self.genesis_hash.as_slice().try_into().map_err(|_| { + RuntimeFailure::host_failure( + METHOD, + format!( + "expected 32-byte genesis hash, got {}", + self.genesis_hash.len() + ), + ) + })?; + let (backend, mut driver) = ChainHeadBackend::::builder() + .build(RpcClient::new(self.rpc_client.clone())); + // The pump holds only a weak handle so a torn-down connection is not + // kept alive by its own driver task. + let pump_connection = Arc::downgrade(&self); + (self.spawner)( + async move { + while let Some(result) = driver.next().await { + if let Err(error) = result { + tracing::debug!(target: "subxt", "chainHead backend error={error}"); + } + } + // The backend can make no further progress; drop the cached + // Subxt bundle so the next caller rebuilds instead of hitting + // a permanently dead backend. + if let Some(connection) = pump_connection.upgrade() { + connection.invalidate_subxt_connection(generation); + } + } + .boxed(), + ); + let backend = Arc::new(backend); + let config = SubstrateConfigBuilder::new() + .set_genesis_hash(H256(genesis_hash)) + .build(); + let client = OnlineClient::from_backend_with_config(config, backend.clone()) + .await + .map_err(|error| RuntimeFailure::host_failure(METHOD, error.to_string()))?; + Ok(SubxtConnection { client }) + } + fn follow_with_runtime(&self, local_follow_id: &str) -> bool { self.follows .lock() @@ -667,16 +819,14 @@ impl ChainConnection { &self, local_follow_id: &str, with_runtime: bool, - sender: Option>, + sender: mpsc::UnboundedSender, cancelled: Arc, ) { let mut follows = self.follows.lock().unwrap(); match follows.get_mut(local_follow_id) { Some(follow) => { - if sender.is_some() { - follow.sender = sender; - follow.cancelled = cancelled; - } + follow.sender = sender; + follow.cancelled = cancelled; } None => { follows.insert( @@ -805,6 +955,7 @@ impl ChainConnection { let remote_follow_id = follow .subscription_id() .ok_or_else(|| { + self.remove_follow(&local_follow_id); RuntimeFailure::host_failure(FOLLOW_METHOD, "missing follow subscription id") })? .to_string(); @@ -823,18 +974,18 @@ impl ChainConnection { Ok(event) => match map_follow_event(event) { Ok(item) => { let is_stop = matches!(item, RemoteChainHeadFollowItem::Stop); - connection.deliver_follow_event(&pump_follow_id, item, false); + connection.deliver_follow_event(&pump_follow_id, item); if is_stop { break; } } Err(_) => { - connection.interrupt_follow(&pump_follow_id, false); + connection.interrupt_follow(&pump_follow_id); break; } }, Err(_) => { - connection.interrupt_follow(&pump_follow_id, false); + connection.interrupt_follow(&pump_follow_id); break; } } @@ -883,46 +1034,31 @@ impl ChainConnection { self.remove_follow(local_follow_id); } - fn deliver_follow_event( - &self, - local_follow_id: &str, - event: RemoteChainHeadFollowItem, - abort_on_stop: bool, - ) { + /// Deliver one follow event to the local subscriber; a `Stop` event also + /// tears the follow down, ending the local stream via sender drop. + /// Cleanup never aborts: the only caller is the pump itself, which the + /// stored abort handle targets. + fn deliver_follow_event(&self, local_follow_id: &str, event: RemoteChainHeadFollowItem) { let sender = self .follows .lock() .unwrap() .get(local_follow_id) - .and_then(|follow| follow.sender.clone()); + .map(|follow| follow.sender.clone()); let is_stop = matches!(event, RemoteChainHeadFollowItem::Stop); if let Some(sender) = sender { - let _ = sender.unbounded_send(FollowSignal::Item(event)); + let _ = sender.unbounded_send(event); } if is_stop { - if abort_on_stop { - self.remove_follow(local_follow_id); - } else { - self.remove_follow_without_abort(local_follow_id); - } + self.remove_follow_without_abort(local_follow_id); } } - fn interrupt_follow(&self, local_follow_id: &str, abort: bool) { - let sender = self - .follows - .lock() - .unwrap() - .get(local_follow_id) - .and_then(|follow| follow.sender.clone()); - if let Some(sender) = sender { - let _ = sender.unbounded_send(FollowSignal::Interrupt); - } - if abort { - self.remove_follow(local_follow_id); - } else { - self.remove_follow_without_abort(local_follow_id); - } + /// End the local follow stream on an abnormal close by tearing the follow + /// down (sender drop). Cleanup never aborts, same as + /// [`Self::deliver_follow_event`]. + fn interrupt_follow(&self, local_follow_id: &str) { + self.remove_follow_without_abort(local_follow_id); } } @@ -930,7 +1066,9 @@ struct FollowState { with_runtime: bool, remote_subscription_id: Option, abort: Option, - sender: Option>, + /// Local subscriber; dropping it (with the follow state) is what ends the + /// local follow stream. + sender: mpsc::UnboundedSender, cancelled: Arc, } @@ -1194,7 +1332,6 @@ async fn wait_for_chain_head_best_hash_after_initialization( ) -> Result, String> { let timeout = futures_timer::Delay::new(timeout).fuse(); pin_mut!(timeout); - let mut candidate = fallback; loop { let next = follow.next().fuse(); pin_mut!(next); @@ -1203,16 +1340,13 @@ async fn wait_for_chain_head_best_hash_after_initialization( Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { return Ok(best_block_hash); } - Some(RemoteChainHeadFollowItem::NewBlock { block_hash, .. }) => { - candidate = Some(block_hash); - } Some(RemoteChainHeadFollowItem::Stop) | None => { return Err(format!("{label} follow stopped before best block")); } _ => {} }, () = timeout => { - return candidate.ok_or_else(|| { + return fallback.clone().ok_or_else(|| { format!("{label} follow best block timed out") }); }, @@ -1220,15 +1354,30 @@ async fn wait_for_chain_head_best_hash_after_initialization( } } +/// Context for one storage operation observed on a `chainHead_v1_follow` stream. +pub(crate) struct ChainHeadStorageValueLookup<'a> { + pub(crate) chain: &'a ChainRuntime, + pub(crate) genesis_hash: &'a [u8], + pub(crate) follow_subscription_id: &'a str, + pub(crate) operation_id: &'a str, + pub(crate) key: &'a [u8], + pub(crate) label: &'static str, + pub(crate) timeout: Duration, +} + +/// Result of one value query observed on a `chainHead_v1_follow` stream. +pub(crate) enum ChainHeadStorageValue { + Found(Vec), + Missing, + Inaccessible, +} + /// Wait for one storage operation's value from a `chainHead_v1_follow` stream. pub(crate) async fn wait_for_chain_head_storage_value( follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - operation_id: &str, - key: &[u8], - label: &'static str, - timeout: Duration, -) -> Result>, String> { - let timeout = futures_timer::Delay::new(timeout).fuse(); + lookup: ChainHeadStorageValueLookup<'_>, +) -> Result { + let timeout = futures_timer::Delay::new(lookup.timeout).fuse(); pin_mut!(timeout); let mut value = None; loop { @@ -1237,35 +1386,51 @@ pub(crate) async fn wait_for_chain_head_storage_value( futures::select! { item = next => match item { Some(RemoteChainHeadFollowItem::OperationStorageItems { operation_id: item_operation_id, items }) - if item_operation_id == operation_id => + if item_operation_id == lookup.operation_id => { for item in items { - if item.key == key { + if item.key == lookup.key { value = item.value; } } } Some(RemoteChainHeadFollowItem::OperationStorageDone { operation_id: item_operation_id }) - if item_operation_id == operation_id => + if item_operation_id == lookup.operation_id => { - return Ok(value); + 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 == operation_id => + if item_operation_id == lookup.operation_id => { - return Ok(None); + return Ok(ChainHeadStorageValue::Inaccessible); } Some(RemoteChainHeadFollowItem::OperationError { operation_id: item_operation_id, error }) - if item_operation_id == operation_id => + if item_operation_id == lookup.operation_id => { return Err(error); } Some(RemoteChainHeadFollowItem::Stop) | None => { - return Err(format!("{label} follow stopped during storage lookup")); + return Err(format!("{} follow stopped during storage lookup", lookup.label)); } _ => {} }, - () = timeout => return Err(format!("{label} storage lookup timed out")), + () = timeout => return Err(format!("{} storage lookup timed out", lookup.label)), } } } @@ -1318,6 +1483,33 @@ mod tests { assert_eq!(hash, vec![0x02]); } + #[test] + fn chain_head_best_hash_timeout_falls_back_to_finalized_not_new_block() { + let mut follow = stream::iter(vec![ + RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes: vec![vec![0x01]], + finalized_block_runtime: None, + }, + RemoteChainHeadFollowItem::NewBlock { + block_hash: vec![0x03], + parent_block_hash: vec![0x01], + new_runtime: None, + }, + ]) + .chain(stream::pending()) + .boxed(); + + let hash = futures::executor::block_on(wait_for_chain_head_best_hash( + &mut follow, + "test chain", + Duration::from_secs(10), + Duration::from_millis(1), + )) + .expect("best hash should fall back to finalized hash"); + + assert_eq!(hash, vec![0x01]); + } + #[test] fn chain_head_best_hash_errors_on_stop_before_best_block() { let mut follow = stream::iter(vec![ @@ -1613,6 +1805,88 @@ mod tests { assert_eq!(provider.inner.connect_calls.load(Ordering::SeqCst), 1); } + /// The cached Subxt bundle must pin the host-configured genesis hash + /// (never fetch it from the provider) and be built once per connection. + #[cfg_attr(target_arch = "wasm32", ignore)] + #[test] + fn subxt_connection_pins_configured_genesis_and_is_cached() { + let provider = Arc::new(ScriptedProvider::new(|_| None)); + let runtime = ChainRuntime::new(provider.clone(), spawner_for_tests()); + let genesis = vec![0xab; 32]; + + let connection = + futures::executor::block_on(runtime.connection_for("subxt_connection_test", &genesis)) + .expect("connection"); + let first = futures::executor::block_on(connection.subxt_connection()).expect("client"); + let second = futures::executor::block_on(connection.subxt_connection()).expect("client"); + + // With the config pin, construction never asks the provider for the + // genesis hash. The scripted provider answers nothing, so a fetch + // would have hung instead of returning. + assert_eq!(first.client.genesis_hash(), H256([0xab; 32])); + assert_eq!(second.client.genesis_hash(), H256([0xab; 32])); + let sent = provider.sent.lock().unwrap().clone(); + assert!( + !sent + .iter() + .any(|request| request.contains("chainSpec_v1_genesisHash")), + "genesis hash must come from config, not the provider; sent: {sent:?}", + ); + + // One backend driver total: its follow subscription shows up once. + let sent = wait_for_sent(&provider, |sent| { + sent.iter() + .any(|request| request.contains("chainHead_v1_follow")) + }); + assert!( + sent.iter() + .any(|request| request.contains("chainHead_v1_follow")), + "backend follow did not start; sent: {sent:?}", + ); + std::thread::sleep(std::time::Duration::from_millis(200)); + let follows = provider + .sent + .lock() + .unwrap() + .iter() + .filter(|request| request.contains("chainHead_v1_follow")) + .count(); + assert_eq!( + follows, 1, + "cached Subxt bundle must reuse one backend follow" + ); + } + + /// When the backend driver exits (transport gone quiet for good), the + /// cached Subxt bundle must be dropped so the next caller rebuilds it. + #[cfg_attr(target_arch = "wasm32", ignore)] + #[test] + fn subxt_connection_invalidated_when_backend_driver_exits() { + let provider = Arc::new(ScriptedProvider::new(|_| None)); + let runtime = ChainRuntime::new(provider.clone(), spawner_for_tests()); + let genesis = vec![0xab; 32]; + + let connection = + futures::executor::block_on(runtime.connection_for("subxt_connection_test", &genesis)) + .expect("connection"); + let _client = futures::executor::block_on(connection.subxt_connection()).expect("client"); + assert!(connection.subxt_connection_setup.lock().unwrap().is_some()); + + // End the response stream: the backend driver's follow stream ends, + // the pump exits, and the exit hook must clear the cached setup. + provider.sender.lock().unwrap().take(); + for _ in 0..500 { + if connection.subxt_connection_setup.lock().unwrap().is_none() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!( + connection.subxt_connection_setup.lock().unwrap().is_none(), + "driver exit must invalidate the cached Subxt bundle", + ); + } + #[test] fn unknown_genesis_chain_spec_propagates_failure() { let provider = Arc::new(UnavailableChainProvider); diff --git a/rust/crates/truapi-server/src/core.rs b/rust/crates/truapi-server/src/core.rs index a9fbd337..cb6df1d5 100644 --- a/rust/crates/truapi-server/src/core.rs +++ b/rust/crates/truapi-server/src/core.rs @@ -58,6 +58,7 @@ impl TrUApiCore { let services = RuntimeServices::new( platform, host_config.people_chain_genesis_hash, + host_config.bulletin_chain_genesis_hash, spawner.clone(), ); let pairing_host = PairingHostRole::new(services.clone(), host_config); diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 8478467c..576aee5b 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -26,8 +26,8 @@ use truapi_platform::{ use crate::core::TrUApiCore; use crate::frame::ProtocolMessage; use crate::runtime::{ - LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, RuntimeServices, - SigningHostRole, + LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, ResponderExit, + RuntimeServices, SigningHostRole, respond_to_pairing, }; use crate::subscription::Spawner; use crate::transport::Transport; @@ -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); @@ -184,9 +185,9 @@ impl PairingHostAdmin for PairingHostRuntime { /// Owns the shared services plus signing-host state. There is no pairing flow, /// so pairing cancellation is not present here. /// -/// Raw-bytes signing and product entropy are implemented; extrinsic-payload -/// signing, transaction construction, ring-VRF aliases, and resource allocation -/// return an `Unavailable` error pending chain-metadata and on-chain support. +/// Raw-bytes and extrinsic-payload signing, v4 transaction construction, and +/// product entropy are implemented; ring-VRF aliases and resource allocation +/// return an `Unavailable` error pending on-chain support. pub struct SigningHostRuntime { services: Arc, signing_host: Arc, @@ -200,9 +201,13 @@ impl SigningHostRuntime { P: Platform + 'static, { let platform: Arc = platform; - let services = - RuntimeServices::new(platform.clone(), config.people_chain_genesis_hash, spawner); - let signing_host = SigningHostRole::new(platform); + let services = RuntimeServices::new( + platform.clone(), + config.people_chain_genesis_hash, + config.bulletin_chain_genesis_hash, + spawner, + ); + let signing_host = SigningHostRole::new(platform, services.clone()); Self { services, signing_host, @@ -247,6 +252,36 @@ impl SigningHostRuntime { reason: err.reason(), }) } + + /// Activate a wallet-local session from host-held secret material and + /// attach known identity metadata. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.activate_local_session"))] + pub async fn activate_local_session_with_identity( + &self, + secret: Vec, + lite_username: Option, + ) -> Result<(), v01::GenericError> { + self.signing_host + .activate_local_session_with_identity(secret, lite_username) + .await + .map_err(|err| v01::GenericError { + reason: err.reason(), + }) + } + + /// Answer a pairing host's handshake deeplink and serve the resulting SSO + /// session until it ends (host-spec §B responder half). Requires an + /// active local session; sensitive requests consult the platform's + /// [`truapi_platform::UserConfirmation`] before signing. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.respond_to_pairing"))] + pub async fn respond_to_pairing( + &self, + deeplink: &str, + ) -> Result { + respond_to_pairing(self.services.clone(), self.signing_host.clone(), deeplink) + .await + .map_err(|reason| v01::GenericError { reason }) + } } /// Product-scoped administration handle for host UI. diff --git a/rust/crates/truapi-server/src/host_logic.rs b/rust/crates/truapi-server/src/host_logic.rs index 0ed7572f..e4040bd9 100644 --- a/rust/crates/truapi-server/src/host_logic.rs +++ b/rust/crates/truapi-server/src/host_logic.rs @@ -4,9 +4,16 @@ //! storage, URL handler, notification center). Everything else lives here so //! iOS, Android, and web hosts share one canonical implementation. -pub mod allowance_signer; +/// Bandersnatch ring-VRF product-account aliases (native signing host only). +#[cfg(not(target_arch = "wasm32"))] +pub mod alias; +/// Lite-person username registration parameters (native signing host only). +#[cfg(not(target_arch = "wasm32"))] +pub mod attestation; +pub mod bulletin; pub mod dotns; pub mod entropy; +pub mod extrinsic; pub mod features; pub mod identity; pub mod permissions; @@ -15,3 +22,4 @@ pub mod session; pub mod session_store; pub mod sso; pub mod statement_store; +pub mod transaction; diff --git a/rust/crates/truapi-server/src/host_logic/alias.rs b/rust/crates/truapi-server/src/host_logic/alias.rs new file mode 100644 index 00000000..6d572350 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/alias.rs @@ -0,0 +1,80 @@ +//! Bandersnatch ring-VRF product-account aliases (signing host). +//! +//! Mirrors the mobile app's `ProductAccountHolder.deriveAlias`: the alias is a +//! thin bandersnatch VRF output over a per-product context, using a +//! bandersnatch secret derived from the wallet's BIP-39 entropy. No ring +//! commitment or SRS is involved (that machinery is only for membership +//! proofs, which this path does not use). +//! +//! Reference: polkadot-app-ios-v2 `Packages/Products/.../ProductAccountHolder.swift` +//! and `verifiable-swift` over `paritytech/verifiable`. + +use blake2_rfc::blake2b::blake2b; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +/// A product-account contextual alias. +pub struct ProductAlias { + /// 32-byte context identifier (blake2b-256 of the derivation path). + pub context: [u8; 32], + /// 32-byte ring-VRF alias output. + pub alias: [u8; 32], +} + +/// Derive the contextual alias for a product account from the wallet entropy. +/// +/// - `context = blake2b_256("/product/{product_id}/{derivation_index}")` +/// - `bandersnatch_entropy = blake2b_256(root_entropy)` +/// - `alias = BandersnatchVrf::alias_in_context(new_secret(bandersnatch_entropy), context)` +pub fn derive_product_alias( + root_entropy: &[u8], + product_id: &str, + derivation_index: u32, +) -> Result { + let derivation_path = format!("/product/{product_id}/{derivation_index}"); + let context = blake2b256(derivation_path.as_bytes()); + let bandersnatch_entropy = blake2b256(root_entropy); + let secret = BandersnatchVrfVerifiable::new_secret(bandersnatch_entropy); + let alias = BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("ring-VRF alias derivation failed: {err:?}"))?; + Ok(ProductAlias { context, alias }) +} + +fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b(32, &[], message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The alias is deterministic in the entropy, product id, and index, and + /// the context is the blake2b-256 of the derivation path. + #[test] + fn alias_is_deterministic_with_expected_context() { + let entropy = [0xABu8; 16]; + let first = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); + let again = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); + + assert_eq!(first.context, again.context); + assert_eq!(first.alias, again.alias); + assert_eq!( + first.context, + blake2b256(b"/product/truapi-playground.dot/0") + ); + } + + #[test] + fn alias_varies_by_product_and_index() { + let entropy = [0xABu8; 16]; + let base = derive_product_alias(&entropy, "a.dot", 0).unwrap(); + let other_product = derive_product_alias(&entropy, "b.dot", 0).unwrap(); + let other_index = derive_product_alias(&entropy, "a.dot", 1).unwrap(); + + assert_ne!(base.alias, other_product.alias); + assert_ne!(base.alias, other_index.alias); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/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/attestation.rs b/rust/crates/truapi-server/src/host_logic/attestation.rs new file mode 100644 index 00000000..53832596 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/attestation.rs @@ -0,0 +1,166 @@ +//! Lite-person username registration parameters (signing host, native only). +//! +//! Builds the client-side proofs the People-chain identity backend needs to +//! attest a lite username for an account: an sr25519 proof-of-ownership, a +//! bandersnatch ring-VRF member key + plain-VRF proof, and an sr25519 +//! consumer-registration signature. The backend submits the on-chain +//! `register_lite_person` extrinsic; the host never signs a chain extrinsic. +//! +//! Byte layout mirrors signing-bot `src/core/attestation.ts` for backend +//! parity. The registered account is the account whose secret signs here; the +//! paired host resolves the username from `Resources.Consumers[that account]`. + +use blake2_rfc::blake2b::blake2b; +use parity_scale_codec::Encode; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use crate::host_logic::product_account::{ + SR25519_SIGNING_CONTEXT, derive_sr25519_hard_path, product_public_key_to_address, +}; +use crate::host_logic::sso::pairing::derive_p256_keypair_from_entropy; + +/// sr25519 proof-of-ownership message prefix (exact bytes; one space). +const REGISTER_PREFIX: &[u8] = b"pop:people-lite:register using"; +/// Domain label for the P-256 identifier key advertised to the backend. +const IDENTIFIER_KEY_LABEL: &[u8] = b"chat-encryption"; + +/// Client-computed parameters for `POST /usernames`. +pub struct LiteRegistration { + /// SS58 (prefix 42) of the candidate account. + pub candidate_account_id: String, + /// Raw 32-byte candidate public key (the future `Resources.Consumers` key). + pub candidate_public_key: [u8; 32], + /// sr25519 signature over `prefix ‖ candidate_pub ‖ ring_vrf_key`. + pub candidate_signature: [u8; 64], + /// Bandersnatch ring-VRF member key. + pub ring_vrf_key: [u8; 32], + /// Plain bandersnatch VRF proof over the same proof message. + pub proof_of_ownership: [u8; 64], + /// 65-byte uncompressed P-256 identifier key. + pub identifier_key: [u8; 65], + /// sr25519 signature over the SCALE consumer-registration tuple. + pub consumer_registration_signature: [u8; 64], +} + +/// Build the lite-person registration parameters for `username_base` +/// (6+ lowercase letters, no digit suffix) against the backend `verifier`. +pub fn build_lite_registration( + entropy: &[u8], + verifier_account_id: [u8; 32], + username_base: &str, +) -> Result { + // The candidate is the `//wallet//sso` statement account, matching the + // account the SSO responder presents as `identity_account_id`. + let candidate = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|err| format!("//wallet//sso derivation failed: {err}"))?; + let candidate_public_key = candidate.public.to_bytes(); + + let vrf_entropy = blake2b256(entropy); + let vrf_secret = BandersnatchVrfVerifiable::new_secret(vrf_entropy); + let ring_vrf_key = BandersnatchVrfVerifiable::member_from_secret(&vrf_secret); + + let mut proof_message = Vec::with_capacity(REGISTER_PREFIX.len() + 64); + proof_message.extend_from_slice(REGISTER_PREFIX); + proof_message.extend_from_slice(&candidate_public_key); + proof_message.extend_from_slice(&ring_vrf_key); + + let candidate_signature = candidate + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &proof_message, &candidate.public) + .to_bytes(); + let proof_of_ownership = BandersnatchVrfVerifiable::sign(&vrf_secret, &proof_message) + .map_err(|err| format!("ring-VRF proof-of-ownership failed: {err:?}"))?; + + let (_identifier_secret, identifier_key) = + derive_p256_keypair_from_entropy(entropy, IDENTIFIER_KEY_LABEL) + .map_err(|err| format!("identifier key derivation failed: {err}"))?; + + // SCALE Tuple(Bytes(32), Bytes(32), Bytes(65), str, Option(Bytes())=None). + let mut consumer_message = Vec::new(); + consumer_message.extend_from_slice(&candidate_public_key); + consumer_message.extend_from_slice(&verifier_account_id); + consumer_message.extend_from_slice(&identifier_key); + consumer_message.extend_from_slice(&username_base.encode()); + consumer_message.push(0u8); + let consumer_registration_signature = candidate + .secret + .sign_simple( + SR25519_SIGNING_CONTEXT, + &consumer_message, + &candidate.public, + ) + .to_bytes(); + + Ok(LiteRegistration { + candidate_account_id: product_public_key_to_address(candidate_public_key), + candidate_public_key, + candidate_signature, + ring_vrf_key, + proof_of_ownership, + identifier_key, + consumer_registration_signature, + }) +} + +fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b(32, &[], message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + use schnorrkel::{PublicKey, Signature}; + + const ENTROPY: [u8; 16] = [0xAB; 16]; + + #[test] + fn registration_params_have_expected_shapes_and_verify() { + let verifier = [0x11u8; 32]; + let reg = build_lite_registration(&ENTROPY, verifier, "headlesstester").unwrap(); + + assert_eq!(reg.identifier_key[0], 0x04, "P-256 uncompressed prefix"); + assert!( + reg.candidate_account_id + .chars() + .all(|c| c.is_alphanumeric()) + ); + + // candidateSignature verifies over prefix ‖ candidate_pub ‖ ring_vrf_key. + let mut proof_message = Vec::new(); + proof_message.extend_from_slice(REGISTER_PREFIX); + proof_message.extend_from_slice(®.candidate_public_key); + proof_message.extend_from_slice(®.ring_vrf_key); + let public = PublicKey::from_bytes(®.candidate_public_key).unwrap(); + let sig = Signature::from_bytes(®.candidate_signature).unwrap(); + assert!( + public + .verify_simple(SR25519_SIGNING_CONTEXT, &proof_message, &sig) + .is_ok(), + "candidate signature verifies" + ); + + // proofOfOwnership verifies as a plain VRF signature for the member key. + assert!( + BandersnatchVrfVerifiable::verify_signature( + ®.proof_of_ownership, + &proof_message, + ®.ring_vrf_key + ), + "ring-VRF proof-of-ownership validates against the member key" + ); + } + + #[test] + fn registration_is_deterministic_per_entropy_and_username() { + let verifier = [0x22u8; 32]; + let first = build_lite_registration(&ENTROPY, verifier, "aliceheadless").unwrap(); + let again = build_lite_registration(&ENTROPY, verifier, "aliceheadless").unwrap(); + assert_eq!(first.candidate_public_key, again.candidate_public_key); + assert_eq!(first.ring_vrf_key, again.ring_vrf_key); + assert_eq!(first.candidate_account_id, again.candidate_account_id); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/bulletin.rs b/rust/crates/truapi-server/src/host_logic/bulletin.rs new file mode 100644 index 00000000..cbdabae7 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/bulletin.rs @@ -0,0 +1,476 @@ +//! Bulletin `TransactionStorage.store` construction and signing. +//! +//! This module is the only place allowance-key material becomes a signer, and +//! it only ever signs the `store` call it builds itself: the public surface +//! takes raw preimage bytes plus a [`BulletinAllowanceKey`], never +//! caller-supplied call data. + +use parity_scale_codec::{Compact, Encode}; +use subxt::client::{ClientAtBlock, OnlineClientAtBlockT}; +use subxt::config::DefaultExtrinsicParamsBuilder; +use subxt::config::substrate::SubstrateConfig; +use subxt::error::ExtrinsicError; +use subxt::ext::scale_encode::{self, EncodeAsFields, FieldIter, TypeResolver}; +use subxt::ext::scale_type_resolver::{Primitive, visitor}; +use subxt::tx::{StaticPayload, SubmittableTransaction}; +use truapi_platform::BulletinAllowanceKey; + +use crate::host_logic::extrinsic::Sr25519Signer; + +pub(crate) const STORE_PALLET_NAME: &str = "TransactionStorage"; +pub(crate) const STORE_CALL_NAME: &str = "store"; + +/// Mortality window for store transactions. +const MORTAL_PERIOD_BLOCKS: u64 = 64; + +/// Preimage key: blake2b-256 of the raw preimage bytes. +pub(crate) fn preimage_key(value: &[u8]) -> [u8; 32] { + sp_crypto_hashing::blake2_256(value) +} + +/// Build and sign a `TransactionStorage.store { data }` transaction with the +/// Bulletin allowance signer against the client's block. Subxt chooses the +/// supported transaction version and injects the nonce and mortality anchor +/// from that same at-block client, so signing and dry-run stay aligned. +pub(crate) async fn build_signed_store_transaction>( + client: &ClientAtBlock, + signer: &Sr25519Signer, + data: &[u8], +) -> Result, ExtrinsicError> { + let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(data)); + let params = DefaultExtrinsicParamsBuilder::::new() + .mortal(MORTAL_PERIOD_BLOCKS) + .build(); + let mut tx = client.tx(); + tx.create_signed(&payload, signer, params).await +} + +/// The only [`BulletinAllowanceKey`] -> signer conversion in the crate. The +/// returned signer is a transient per-call value; callers must not store it. +pub(crate) fn allowance_signer(allowance: &BulletinAllowanceKey) -> Result { + Sr25519Signer::from_secret_bytes(allowance.as_secret_bytes()) + .map_err(|reason| format!("invalid bulletin allowance key: {reason}")) +} + +/// `store { data: Vec }` call arguments with a byte-level fast path. +/// +/// scale-encode has no `u8` specialization, so encoding the preimage as a +/// `Vec` value would pay a registry resolution and visitor dispatch per +/// byte, twice per transaction. This implementation verifies once that the +/// `data` field resolves to a sequence of `u8` (hard error otherwise, so +/// metadata lies about the argument type are rejected) and then memcpys. +struct StoreCallData<'a>(&'a [u8]); + +impl EncodeAsFields for StoreCallData<'_> { + fn encode_as_fields_to( + &self, + fields: &mut dyn FieldIter<'_, R::TypeId>, + types: &R, + out: &mut Vec, + ) -> Result<(), scale_encode::Error> { + let field = fields + .next() + .ok_or_else(|| scale_encode::Error::custom_str("store call has no data field"))?; + if fields.next().is_some() { + return Err(scale_encode::Error::custom_str( + "store call has more than one field", + )); + } + require_u8_sequence(types, field.id)?; + + Compact(self.0.len() as u32).encode_to(out); + out.extend_from_slice(self.0); + Ok(()) + } +} + +/// Hard-error unless `type_id` resolves to a sequence whose element type is +/// the `u8` primitive. +fn require_u8_sequence( + types: &R, + type_id: R::TypeId, +) -> Result<(), scale_encode::Error> { + let sequence_visitor = visitor::new((), |(), _kind| None::) + .visit_sequence(|(), _path, inner| Some(inner)); + let inner = types + .resolve_type(type_id, sequence_visitor) + .map_err(|err| scale_encode::Error::custom_string(err.to_string()))? + .ok_or_else(|| { + scale_encode::Error::custom_str("store data field is not a byte sequence") + })?; + + let u8_visitor = visitor::new((), |(), _kind| false) + .visit_primitive(|(), primitive| matches!(primitive, Primitive::U8)); + let is_u8 = types + .resolve_type(inner, u8_visitor) + .map_err(|err| scale_encode::Error::custom_string(err.to_string()))?; + if !is_u8 { + return Err(scale_encode::Error::custom_str( + "store data field is not a sequence of u8", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_logic::extrinsic::tests::{OfflineChainState, bulletin_chain_state, split_v4}; + use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; + use parity_scale_codec::Decode; + use schnorrkel::{PublicKey, Signature}; + use subxt::client::{ClientAtBlock, OfflineClientAtBlockT}; + use subxt::ext::frame_metadata::{RuntimeMetadata, RuntimeMetadataPrefixed, v14}; + use subxt::metadata::{ArcMetadata, Metadata}; + use subxt::tx::Signer; + use subxt::utils::H256; + + #[derive(Debug, Clone, Copy)] + struct TestMortalityAnchor { + number: u64, + hash: [u8; 32], + } + + fn allowance_fixture() -> BulletinAllowanceKey { + let secret = hex::decode( + "0eef5183411d40c32446bb1cbaabd70004a17af6012a577c735d054f04059208\ + 573dfc9b6ffeb1c786a16349e70f9836876a743c31c0a7a2a70727a852eec372", + ) + .unwrap(); + BulletinAllowanceKey::from_secret_bytes(secret).unwrap() + } + + fn signer_fixture() -> Sr25519Signer { + allowance_signer(&allowance_fixture()).unwrap() + } + + fn anchor_fixture() -> TestMortalityAnchor { + TestMortalityAnchor { + number: 4200, + hash: [0xaa; 32], + } + } + + fn build_signed_store_transaction_offline>( + client: &ClientAtBlock, + anchor: &TestMortalityAnchor, + signer: &Sr25519Signer, + nonce: u64, + data: &[u8], + ) -> Result, String> { + let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(data)); + let params = DefaultExtrinsicParamsBuilder::::new() + .nonce(nonce) + .mortal_from_unchecked(MORTAL_PERIOD_BLOCKS, anchor.number, H256(anchor.hash)) + .build(); + client + .tx() + .create_v4_signable_offline(&payload, params) + .map_err(|err| format!("store transaction assembly failed: {err}"))? + .sign(signer) + .map_err(|err| format!("store transaction signing failed: {err}")) + } + + /// Decode the fixture metadata down to its mutable v14 representation. + fn bulletin_metadata_v14() -> v14::RuntimeMetadataV14 { + let prefixed = RuntimeMetadataPrefixed::decode( + &mut &crate::host_logic::extrinsic::tests::BULLETIN_METADATA_BYTES[..], + ) + .unwrap(); + match prefixed.1 { + RuntimeMetadata::V14(metadata) => metadata, + other => panic!("expected v14 fixture metadata, got {other:?}"), + } + } + + fn metadata_from_v14(metadata: v14::RuntimeMetadataV14) -> ArcMetadata { + let prefixed = + RuntimeMetadataPrefixed(u32::from_le_bytes(*b"meta"), RuntimeMetadata::V14(metadata)); + ArcMetadata::from(Metadata::try_from(prefixed).unwrap()) + } + + fn state_with_metadata(metadata: ArcMetadata) -> OfflineChainState { + OfflineChainState { + metadata, + ..bulletin_chain_state() + } + } + + fn extension_by_identifier( + metadata: &v14::RuntimeMetadataV14, + identifier: &str, + ) -> v14::SignedExtensionMetadata { + metadata + .extrinsic + .signed_extensions + .iter() + .find(|extension| extension.identifier == identifier) + .unwrap_or_else(|| panic!("fixture metadata lacks the {identifier} extension")) + .clone() + } + + #[test] + fn preimage_key_is_blake2b_256() { + assert_eq!( + hex::encode(preimage_key(b"")), + "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8" + ); + } + + #[test] + fn builds_and_signs_store_extrinsic_against_fixture() { + let state = bulletin_chain_state(); + let data = b"hello bulletin".to_vec(); + let client = state.client_at(anchor_fixture().number).unwrap(); + let signed = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 7, + &data, + ) + .unwrap(); + + let (account, signature, tail) = split_v4(signed.encoded()); + assert_eq!(account, signer_fixture().account_id().0); + let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(&data)); + let call_data = client.tx().call_data(&payload).unwrap(); + assert!(tail.ends_with(&call_data)); + + // The signature must verify over the reconstructed signer payload. + let params = DefaultExtrinsicParamsBuilder::::new() + .nonce(7) + .mortal_from_unchecked( + MORTAL_PERIOD_BLOCKS, + anchor_fixture().number, + H256(anchor_fixture().hash), + ) + .build(); + let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(&data)); + let signer_payload = client + .tx() + .create_v4_signable_offline(&payload, params) + .unwrap() + .signer_payload() + .unwrap(); + let public = PublicKey::from_bytes(&account).unwrap(); + assert!( + public + .verify_simple( + SR25519_SIGNING_CONTEXT, + &signer_payload, + &Signature::from_bytes(&signature).unwrap() + ) + .is_ok() + ); + } + + #[test] + fn genesis_hash_binds_the_signature() { + let data = b"pinned to one chain".to_vec(); + let client = bulletin_chain_state() + .client_at(anchor_fixture().number) + .unwrap(); + let signed = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 0, + &data, + ) + .unwrap(); + let (account, signature, _) = split_v4(signed.encoded()); + + let mutated_state = OfflineChainState { + genesis_hash: [0xcc; 32], + ..bulletin_chain_state() + }; + let client = mutated_state.client_at(anchor_fixture().number).unwrap(); + let params = DefaultExtrinsicParamsBuilder::::new() + .mortal_from_unchecked( + MORTAL_PERIOD_BLOCKS, + anchor_fixture().number, + H256(anchor_fixture().hash), + ) + .build(); + let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(&data)); + let mutated_payload = client + .tx() + .create_v4_signable_offline(&payload, params) + .unwrap() + .signer_payload() + .unwrap(); + + let public = PublicKey::from_bytes(&account).unwrap(); + assert!( + public + .verify_simple( + SR25519_SIGNING_CONTEXT, + &mutated_payload, + &Signature::from_bytes(&signature).unwrap() + ) + .is_err() + ); + } + + #[test] + fn rejects_mutated_store_argument_type() { + // Point the store call's `data` field at a non-u8-sequence type: the + // CheckSpecVersion additional (u32) borrowed from the extension list. + let mut metadata = bulletin_metadata_v14(); + let u32_type = extension_by_identifier(&metadata, "CheckSpecVersion").additional_signed; + let calls_type_id = metadata + .pallets + .iter() + .find(|pallet| pallet.name == "TransactionStorage") + .unwrap() + .calls + .as_ref() + .unwrap() + .ty + .id; + let calls_type = metadata + .types + .types + .iter_mut() + .find(|ty| ty.id == calls_type_id) + .unwrap(); + let scale_info::TypeDef::Variant(variants) = &mut calls_type.ty.type_def else { + panic!("calls type is not a variant"); + }; + let store = variants + .variants + .iter_mut() + .find(|variant| variant.name == "store") + .unwrap(); + store.fields[0].ty = u32_type; + + let state = state_with_metadata(metadata_from_v14(metadata)); + let client = state.client_at(anchor_fixture().number).unwrap(); + let error = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 0, + &[1, 2, 3], + ) + .unwrap_err(); + assert!(error.contains("not a"), "{error}"); + } + + #[test] + fn unknown_extension_with_non_empty_implicit_errors() { + let mut metadata = bulletin_metadata_v14(); + let mut fake = extension_by_identifier(&metadata, "CheckSpecVersion"); + fake.identifier = "FakeImplicitExt".to_string(); + metadata.extrinsic.signed_extensions.push(fake); + + let state = state_with_metadata(metadata_from_v14(metadata)); + let client = state.client_at(anchor_fixture().number).unwrap(); + let error = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 0, + &[1], + ) + .unwrap_err(); + assert!(error.contains("FakeImplicitExt"), "{error}"); + } + + #[test] + fn unknown_extension_with_non_empty_value_errors() { + let mut metadata = bulletin_metadata_v14(); + let mut fake = extension_by_identifier(&metadata, "CheckNonce"); + fake.identifier = "FakeValueExt".to_string(); + metadata.extrinsic.signed_extensions.push(fake); + + let state = state_with_metadata(metadata_from_v14(metadata)); + let client = state.client_at(anchor_fixture().number).unwrap(); + let error = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 0, + &[1], + ) + .unwrap_err(); + assert!(error.contains("FakeValueExt"), "{error}"); + } + + #[test] + fn unknown_extension_with_option_value_encodes_none() { + // Accepted gap: an unknown extension whose extra is a bare `Option` + // silently encodes `None` instead of erroring. + let mut metadata = bulletin_metadata_v14(); + let option_type = extension_by_identifier(&metadata, "CheckMetadataHash").additional_signed; + let empty_type = extension_by_identifier(&metadata, "CheckSpecVersion").ty; + let mut fake = extension_by_identifier(&metadata, "CheckSpecVersion"); + fake.identifier = "FakeOptionExt".to_string(); + fake.ty = option_type; + fake.additional_signed = empty_type; + metadata.extrinsic.signed_extensions.push(fake); + + let state = state_with_metadata(metadata_from_v14(metadata)); + let baseline_client = bulletin_chain_state() + .client_at(anchor_fixture().number) + .unwrap(); + let baseline = build_signed_store_transaction_offline( + &baseline_client, + &anchor_fixture(), + &signer_fixture(), + 0, + &[1], + ) + .unwrap(); + let client = state.client_at(anchor_fixture().number).unwrap(); + let with_fake = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 0, + &[1], + ) + .unwrap(); + assert_eq!( + with_fake.encoded().len(), + baseline.encoded().len() + 1, + "the Option-typed extra should contribute exactly one None byte" + ); + } + + #[test] + fn builds_large_preimage_without_pathological_cost() { + // The store call data must not encode per byte (scale-encode has no u8 + // fast path); the StoreCallData memcpy keeps an 8 MiB preimage cheap. + // A generous bound catches an O(n^2)/per-byte-visitor regression + // without being flaky under CI load. + let data = vec![0x5au8; 8 * 1024 * 1024]; + let client = bulletin_chain_state() + .client_at(anchor_fixture().number) + .unwrap(); + let start = std::time::Instant::now(); + let signed = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 0, + &data, + ) + .unwrap(); + let elapsed = start.elapsed(); + assert!(signed.encoded().len() > data.len()); + assert!( + elapsed < std::time::Duration::from_secs(5), + "building an 8 MiB store extrinsic took {elapsed:?}" + ); + } + + #[test] + fn rejects_secret_of_wrong_shape() { + let error = + allowance_signer(&BulletinAllowanceKey::from_secret_bytes(vec![0xff; 64]).unwrap()) + .unwrap_err(); + assert!(error.contains("invalid bulletin allowance key"), "{error}"); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs index 1c9323d9..47176f2f 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"; @@ -24,8 +23,14 @@ pub fn derive_product_entropy( product_id: &str, key: &[u8], ) -> Result<[u8; 32], ProductEntropyError> { - let root_entropy_source = blake2b256_keyed(entropy_secret, DOMAIN_SEPARATOR); - derive_product_entropy_from_source(&root_entropy_source, product_id, key) + derive_product_entropy_from_source(&root_entropy_source(entropy_secret), product_id, key) +} + +/// Pre-hashed root entropy source (RFC-0007 layer 1). Signing hosts share this +/// value with paired hosts during the SSO handshake so both sides derive the +/// same product entropy. +pub fn root_entropy_source(entropy_secret: &[u8]) -> [u8; 32] { + blake2b256_keyed(entropy_secret, DOMAIN_SEPARATOR) } /// Derive product-scoped entropy when the session already stores the @@ -45,8 +50,11 @@ pub fn derive_product_entropy_from_source( } fn blake2b256_keyed(message: &[u8], key: &[u8]) -> [u8; 32] { - let hash = blake2b(32, key, message); - hash.as_bytes() + blake2b_simd::Params::new() + .hash_length(32) + .key(key) + .hash(message) + .as_bytes() .try_into() .expect("BLAKE2b-256 returns 32 bytes") } diff --git a/rust/crates/truapi-server/src/host_logic/extrinsic.rs b/rust/crates/truapi-server/src/host_logic/extrinsic.rs new file mode 100644 index 00000000..713ed740 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/extrinsic.rs @@ -0,0 +1,363 @@ +//! sr25519 transaction signing shared by chain-facing runtime services. +//! +//! [`Sr25519Signer`] is the one subxt [`Signer`] in the crate; Bulletin +//! preimage submission and the signing-host product key both go through it. +//! [`build_signed_extrinsic_v4`] assembles a signed V4 extrinsic from +//! caller-supplied, already-SCALE-encoded parts (local `create_transaction`), +//! so it needs no metadata at all. + +use parity_scale_codec::Encode; +use schnorrkel::{PublicKey, SecretKey}; +use subxt::config::substrate::SubstrateConfig; +use subxt::tx::Signer; +use subxt::utils::{AccountId32, MultiAddress, MultiSignature}; +use truapi::v01; + +use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; + +/// Parse a 64-byte sr25519 secret in either of the two wire encodings. +/// +/// Rust-generated keys use schnorrkel's canonical scalar bytes; legacy +/// JS-derived keys use scure/ed25519-expanded scalar bytes. Signatures are +/// identical for both once parsed, so callers never need to know which form +/// they hold. +pub(crate) fn sr25519_secret_from_bytes(secret: &[u8; 64]) -> Result { + match SecretKey::from_bytes(secret) { + Ok(secret) => Ok(secret), + Err(canonical_error) => SecretKey::from_ed25519_bytes(secret).map_err(|ed_error| { + format!("invalid sr25519 secret: canonical={canonical_error}; ed25519={ed_error}") + }), + } +} + +/// sr25519 [`Signer`] over a parsed schnorrkel key. +/// +/// Holds only the parsed key (schnorrkel zeroizes it on drop), never the raw +/// secret bytes. +#[derive(derive_more::Debug)] +pub(crate) struct Sr25519Signer { + #[debug("\"\"")] + secret: SecretKey, + #[debug("{}", hex::encode(public.to_bytes()))] + public: PublicKey, +} + +impl Sr25519Signer { + /// Parse a signer from 64-byte secret material. + pub(crate) fn from_secret_bytes(secret: &[u8; 64]) -> Result { + let secret = sr25519_secret_from_bytes(secret)?; + let public = secret.to_public(); + Ok(Self { secret, public }) + } + + /// Build a signer from an already-derived schnorrkel keypair (e.g. a + /// signing-host product key), reusing the same `sign`/`account_id` path. + pub(crate) fn from_keypair(keypair: &schnorrkel::Keypair) -> Self { + Self { + secret: keypair.secret.clone(), + public: keypair.public, + } + } +} + +impl Signer for Sr25519Signer { + fn account_id(&self) -> AccountId32 { + AccountId32(self.public.to_bytes()) + } + + fn sign(&self, signer_payload: &[u8]) -> MultiSignature { + let signature = + self.secret + .sign_simple(SR25519_SIGNING_CONTEXT, signer_payload, &self.public); + MultiSignature::Sr25519(signature.to_bytes()) + } +} + +/// The V4 signer payload: `call_data ++ Σextra ++ Σadditional_signed`, replaced +/// by its blake2_256 hash only when it exceeds 256 bytes. +/// +/// Note the order differs from the extrinsic body (which puts `extra` before +/// the call): the call comes first here, extras next, implicits last. +fn v4_signer_payload(call_data: &[u8], extensions: &[v01::TxPayloadExtension]) -> Vec { + let mut payload = Vec::with_capacity(call_data.len()); + payload.extend_from_slice(call_data); + for ext in extensions { + payload.extend_from_slice(&ext.extra); + } + for ext in extensions { + payload.extend_from_slice(&ext.additional_signed); + } + if payload.len() > 256 { + sp_crypto_hashing::blake2_256(&payload).to_vec() + } else { + payload + } +} + +/// Assemble a signed Extrinsic V4 from caller-supplied, already-SCALE-encoded +/// parts, entirely offline. +/// +/// Body layout (matches `frame_decode::encode_v4_signed` and subxt's own +/// assembler byte-for-byte): +/// +/// ```text +/// Compact(len) ++ 0x84 ++ 0x00 ++ signer(32) ++ 0x01 ++ signature(64) +/// ++ Σ extension.extra ++ call_data +/// ``` +/// +/// `0x84` is the V4 "signed" version byte, `0x00` the `MultiAddress::Id` +/// discriminant, `0x01` the `MultiSignature::Sr25519` discriminant. `extra` +/// bytes go in the body (in the given order, which must be the runtime's +/// canonical extension order); `additional_signed` bytes appear only in the +/// signed payload. The chain binding (genesis, spec/tx version, mortality +/// anchor, nonce, tip) lives inside the caller's extension bytes, so nothing +/// here is metadata-driven. +pub(crate) fn build_signed_extrinsic_v4( + signer: &Sr25519Signer, + call_data: &[u8], + extensions: &[v01::TxPayloadExtension], +) -> Vec { + /// `0b1000_0000 | 4`: the "signed" bit plus extrinsic format version 4. + const EXTRINSIC_V4_SIGNED: u8 = 0x84; + + let signature = signer.sign(&v4_signer_payload(call_data, extensions)); + let address = MultiAddress::::Id(signer.account_id()); + + let mut inner = Vec::new(); + inner.push(EXTRINSIC_V4_SIGNED); + address.encode_to(&mut inner); + signature.encode_to(&mut inner); + for ext in extensions { + inner.extend_from_slice(&ext.extra); + } + inner.extend_from_slice(call_data); + + // `Vec::encode` prepends the SCALE compact length, giving the outer + // length-prefixed opaque extrinsic. + inner.encode() +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use parity_scale_codec::{Compact, Decode}; + use subxt::client::{OfflineClient, OfflineClientAtBlock}; + use subxt::config::substrate::{SpecVersionForRange, SubstrateConfigBuilder}; + use subxt::ext::frame_metadata::RuntimeMetadataPrefixed; + use subxt::metadata::{ArcMetadata, Metadata}; + use subxt::utils::H256; + + /// Everything needed to build transactions offline for one chain at one + /// runtime version, mirroring what the production path gets from the + /// genesis-pinned online client. + pub(crate) struct OfflineChainState { + pub(crate) genesis_hash: [u8; 32], + pub(crate) spec_version: u32, + pub(crate) transaction_version: u32, + pub(crate) metadata: ArcMetadata, + } + + impl OfflineChainState { + /// Build an offline subxt client pinned at `block_number`. + pub(crate) fn client_at( + &self, + block_number: u64, + ) -> Result, String> { + let config = SubstrateConfigBuilder::new() + .set_genesis_hash(H256(self.genesis_hash)) + .set_spec_version_for_block_ranges([SpecVersionForRange { + block_range: 0..u64::MAX, + spec_version: self.spec_version, + transaction_version: self.transaction_version, + }]) + .set_metadata_for_spec_versions([(self.spec_version, self.metadata.clone())]) + .build(); + OfflineClient::new_with_config(config) + .at_block(block_number) + .map_err(|err| format!("offline client unavailable: {err}")) + } + } + + /// Raw `RuntimeMetadataPrefixed` bytes captured from the live + /// bulletin-paseo chain (`state_getMetadata`, spec 1000020, metadata v14). + pub(crate) const BULLETIN_METADATA_BYTES: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/bulletin_paseo_metadata.scale" + )); + + /// Decode the checked-in bulletin metadata fixture. + pub(crate) fn bulletin_metadata() -> Metadata { + let prefixed = RuntimeMetadataPrefixed::decode(&mut &BULLETIN_METADATA_BYTES[..]).unwrap(); + Metadata::try_from(prefixed).unwrap() + } + + /// Offline chain state over the bulletin fixture with a recognizable + /// genesis hash. + pub(crate) fn bulletin_chain_state() -> OfflineChainState { + OfflineChainState { + genesis_hash: [0xbb; 32], + spec_version: 1_000_020, + transaction_version: 1, + metadata: ArcMetadata::from(bulletin_metadata()), + } + } + + #[test] + fn parses_canonical_and_ed25519_expanded_secrets() { + let canonical: [u8; 64] = hex::decode( + "0eef5183411d40c32446bb1cbaabd70004a17af6012a577c735d054f04059208\ + 573dfc9b6ffeb1c786a16349e70f9836876a743c31c0a7a2a70727a852eec372", + ) + .unwrap() + .try_into() + .unwrap(); + assert!(sr25519_secret_from_bytes(&canonical).is_ok()); + + let mini = schnorrkel::MiniSecretKey::from_bytes(&[7; 32]).unwrap(); + let expanded = mini + .expand(schnorrkel::ExpansionMode::Ed25519) + .to_ed25519_bytes(); + let expanded: [u8; 64] = expanded.as_slice().try_into().unwrap(); + let parsed = sr25519_secret_from_bytes(&expanded).unwrap(); + assert_eq!( + parsed.to_public().to_bytes(), + mini.expand_to_keypair(schnorrkel::ExpansionMode::Ed25519) + .public + .to_bytes() + ); + } + + #[test] + fn signer_signs_under_substrate_context() { + let mini = schnorrkel::MiniSecretKey::from_bytes(&[9; 32]).unwrap(); + let secret: [u8; 64] = mini + .expand(schnorrkel::ExpansionMode::Ed25519) + .to_bytes() + .as_slice() + .try_into() + .unwrap(); + let signer = Sr25519Signer::from_secret_bytes(&secret).unwrap(); + + let payload = b"payload"; + let MultiSignature::Sr25519(signature) = signer.sign(payload) else { + panic!("expected sr25519 signature"); + }; + let public = PublicKey::from_bytes(&signer.account_id().0).unwrap(); + assert!( + public + .verify_simple( + SR25519_SIGNING_CONTEXT, + payload, + &schnorrkel::Signature::from_bytes(&signature).unwrap() + ) + .is_ok() + ); + } + + fn test_signer() -> Sr25519Signer { + let keypair = schnorrkel::MiniSecretKey::from_bytes(&[3; 32]) + .unwrap() + .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519); + Sr25519Signer::from_keypair(&keypair) + } + + fn ext(id: &str, extra: &[u8], additional: &[u8]) -> v01::TxPayloadExtension { + v01::TxPayloadExtension { + id: id.to_string(), + extra: extra.to_vec(), + additional_signed: additional.to_vec(), + } + } + + /// Split a length-prefixed V4 signed extrinsic into + /// (account, signature, trailing-bytes-after-signature). + pub(crate) fn split_v4(extrinsic: &[u8]) -> ([u8; 32], [u8; 64], Vec) { + let mut input = extrinsic; + let len = Compact::::decode(&mut input).unwrap().0 as usize; + assert_eq!(input.len(), len, "compact length must cover the remainder"); + assert_eq!(input[0], 0x84, "V4 signed version byte"); + assert_eq!(input[1], 0x00, "MultiAddress::Id discriminant"); + let account: [u8; 32] = input[2..34].try_into().unwrap(); + assert_eq!(input[34], 0x01, "MultiSignature::Sr25519 discriminant"); + let signature: [u8; 64] = input[35..99].try_into().unwrap(); + (account, signature, input[99..].to_vec()) + } + + #[test] + fn v4_layout_and_signature_verify() { + let signer = test_signer(); + let call_data = vec![0x2a, 0x00, 0xde, 0xad]; + let extensions = vec![ + ext("CheckNonce", &[0x04], &[]), + ext("CheckGenesis", &[], &[9; 32]), + ]; + + let extrinsic = build_signed_extrinsic_v4(&signer, &call_data, &extensions); + let (account, signature, tail) = split_v4(&extrinsic); + + // Body tail is Σextra ++ call_data (extra before call). + let mut expected_tail = Vec::new(); + expected_tail.extend_from_slice(&extensions[0].extra); + expected_tail.extend_from_slice(&extensions[1].extra); + expected_tail.extend_from_slice(&call_data); + assert_eq!(account, signer.account_id().0); + assert_eq!(tail, expected_tail); + + // Signature verifies over call ++ Σextra ++ Σadditional (call first). + let mut payload = call_data.clone(); + payload.extend_from_slice(&extensions[0].extra); + payload.extend_from_slice(&extensions[1].extra); + payload.extend_from_slice(&extensions[0].additional_signed); + payload.extend_from_slice(&extensions[1].additional_signed); + let public = PublicKey::from_bytes(&account).unwrap(); + assert!( + public + .verify_simple( + SR25519_SIGNING_CONTEXT, + &payload, + &schnorrkel::Signature::from_bytes(&signature).unwrap() + ) + .is_ok() + ); + } + + #[test] + fn v4_signer_payload_hashes_when_over_256_bytes() { + let signer = test_signer(); + let call_data = vec![1u8; 200]; + // Push the payload (call ++ extra ++ additional) over 256 bytes. + let extensions = vec![ext("Big", &[2u8; 60], &[3u8; 60])]; + let extrinsic = build_signed_extrinsic_v4(&signer, &call_data, &extensions); + let (account, signature, _) = split_v4(&extrinsic); + let public = PublicKey::from_bytes(&account).unwrap(); + let sig = schnorrkel::Signature::from_bytes(&signature).unwrap(); + + let mut raw = call_data.clone(); + raw.extend_from_slice(&extensions[0].extra); + raw.extend_from_slice(&extensions[0].additional_signed); + assert!(raw.len() > 256); + let hashed = sp_crypto_hashing::blake2_256(&raw); + + // Signs over the hash, not the raw concatenation. + assert!( + public + .verify_simple(SR25519_SIGNING_CONTEXT, &hashed, &sig) + .is_ok() + ); + assert!( + public + .verify_simple(SR25519_SIGNING_CONTEXT, &raw, &sig) + .is_err() + ); + } + + #[test] + fn v4_preserves_extension_order() { + let signer = test_signer(); + let call_data = vec![0xff]; + // Deliberately not in sorted order; the assembler must not reorder. + let extensions = vec![ext("B", &[0xbb], &[]), ext("A", &[0xaa], &[])]; + let (_, _, tail) = split_v4(&build_signed_extrinsic_v4(&signer, &call_data, &extensions)); + assert_eq!(tail, vec![0xbb, 0xaa, 0xff]); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs index cfc329aa..a6f75c8a 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,33 @@ 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]); + subxt::utils::AccountId32(public_key).to_string() +} - bs58::encode(payload).into_string() +/// Derive an sr25519 keypair down a path of hard string junctions from the +/// BIP-39 entropy, e.g. `["wallet", "sso"]` for `//wallet//sso`. +/// +/// Matches Substrate hard derivation (schnorrkel `SchnorrRistrettoHDKD`), the +/// same path the mobile/bot signing hosts use for their statement identity +/// account. +pub fn derive_sr25519_hard_path( + entropy: &[u8], + junctions: &[&str], +) -> Result { + let mut keypair = derive_root_keypair_from_entropy(entropy)?; + for junction in junctions { + let chain_code = schnorrkel::derive::ChainCode(create_chain_code(junction)?); + let (mini_secret, _) = keypair + .secret + .hard_derive_mini_secret_key(Some(chain_code), b""); + keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + } + Ok(keypair) } /// Create a Substrate soft-derivation chain code for one junction. @@ -112,8 +124,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/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index 5af8e3b8..fbcc447d 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -30,10 +30,11 @@ use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::pairing::{ AES_GCM_NONCE_LEN, SsoStatementData, decrypt_session_statement_data, encrypt_session_statement_data, encrypt_session_statement_data_with_nonce, + peer_response_channel, }; use crate::host_logic::statement_store::{ - build_signed_session_request_statement, current_unix_secs, decode_verified_statement_data, - statement_expiry_elapsed, + build_signed_session_request_statement, build_signed_statement, current_unix_secs, + decode_verified_statement_data, statement_expiry_elapsed, }; pub mod v1; @@ -136,6 +137,31 @@ impl SigningPayloadRequest { } } +impl From for truapi::v01::HostSignPayloadRequest { + fn from(value: SigningPayloadRequest) -> Self { + Self { + account: value.product_account_id, + payload: truapi::v01::HostSignPayloadData { + block_hash: value.block_hash, + block_number: value.block_number, + era: value.era, + genesis_hash: value.genesis_hash, + method: value.method, + nonce: value.nonce, + spec_version: value.spec_version, + tip: value.tip, + transaction_version: value.transaction_version, + signed_extensions: value.signed_extensions, + version: value.version, + asset_id: value.asset_id, + metadata_hash: value.metadata_hash, + mode: value.mode, + with_signed_transaction: value.with_signed_transaction.0, + }, + } + } +} + /// Request sent when a product asks the paired signing host to sign raw bytes or a /// string message with a product-derived account. /// @@ -188,6 +214,24 @@ impl From for SigningRawPayload { } } +impl From for RawPayload { + fn from(value: SigningRawPayload) -> Self { + match value { + SigningRawPayload::Bytes(bytes) => Self::Bytes { bytes }, + SigningRawPayload::Payload(payload) => Self::Payload { payload }, + } + } +} + +impl From for truapi::v01::HostSignRawRequest { + fn from(value: SigningRawRequest) -> Self { + Self { + account: value.product_account_id, + payload: value.data.into(), + } + } +} + /// Response returned by the signing host for a product-account signing request. /// /// Decoded from [`v1::RemoteMessage::SignResponse`] while the runtime is waiting @@ -215,6 +259,26 @@ pub struct SignRawLegacyResponse { pub signature: Result, String>, } +/// Request sent when a product asks the paired signing host to sign exact +/// statement-store proof bytes with a product-derived account. +/// +/// This cannot reuse raw signing: raw-signing requests apply the public +/// `...` payload convention, while statement proofs sign the +/// unsigned statement payload bytes directly. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct StatementStoreProductSignRequest { + pub product_account_id: ProductAccountId, + pub payload: Vec, +} + +/// Response returned by the signing host for exact statement-store proof +/// signing. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct StatementStoreProductSignResponse { + pub responding_to: String, + pub signature: Result, String>, +} + /// Request sent when a product asks the signing host for a ring-VRF alias. /// /// Used by `Account::get_account_alias`; the product account identifies the @@ -353,6 +417,7 @@ pub enum SsoSessionStatement { pub enum SsoRemoteResponse { Sign(SigningResponse), SignRawLegacy(SignRawLegacyResponse), + StatementStoreProductSign(StatementStoreProductSignResponse), RingVrfAlias(RingVrfAliasResponse), ResourceAllocation(ResourceAllocationResponse), CreateTransaction(CreateTransactionResponse), @@ -452,6 +517,11 @@ fn remote_response_for_message( { Some(SsoRemoteResponse::SignRawLegacy(response)) } + v1::RemoteMessage::StatementStoreProductSignResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::StatementStoreProductSign(response)) + } v1::RemoteMessage::ResourceAllocationResponse(response) if response.responding_to == expected_remote_message_id => { @@ -466,6 +536,23 @@ fn remote_response_for_message( } } +/// Build a signing-host exact statement-store proof signing request message. +pub fn statement_store_product_sign_message( + message_id: String, + product_account_id: ProductAccountId, + payload: Vec, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(v1::RemoteMessage::StatementStoreProductSignRequest( + StatementStoreProductSignRequest { + product_account_id, + payload, + }, + )), + } +} + /// Build a signing-host payload-signing request message. pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) -> RemoteMessage { RemoteMessage { @@ -569,6 +656,81 @@ pub fn create_transaction_legacy_message( } } +/// Inbound request decoded from a peer-signed session statement. +/// +/// `request_id` identifies the statement for the transport-level ack; +/// `messages` are the application messages batched into it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IncomingSsoRequest { + pub request_id: String, + pub messages: Vec, +} + +/// Decode an inbound session statement into the peer's request batch. +/// +/// Returns `Ok(None)` for statements that carry no peer request: own echoes, +/// transport-level acks, and expired statements. Used by the signing-host +/// responder, which serves peer requests instead of matching pending ones. +pub fn decode_incoming_sso_request( + session: &SsoSessionInfo, + statement: &[u8], +) -> Result, String> { + let verified = + decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; + if verified.signer == session.ss_public_key { + return Ok(None); + } + if verified.signer != session.identity_account_id { + return Err("statement proof signer does not match expected peer".to_string()); + } + if verified + .expiry + .is_some_and(|expiry| statement_expiry_elapsed(expiry, current_unix_secs())) + { + return Ok(None); + } + match decrypt_session_statement_data(session, &verified.data)? { + SsoStatementData::Response { .. } => Ok(None), + SsoStatementData::Request { request_id, data } => { + let messages = data + .iter() + .map(|message| { + RemoteMessage::decode(&mut message.as_slice()) + .map_err(|err| format!("invalid SSO remote message: {err}")) + }) + .collect::, _>>()?; + Ok(Some(IncomingSsoRequest { + request_id, + messages, + })) + } + } +} + +/// Build the signed transport-level ack for a peer-initiated request +/// statement: topic `session_id_peer`, channel [`peer_response_channel`]. +pub fn build_signed_session_response_statement( + session: &SsoSessionInfo, + request_id: String, + response_code: u8, + expiry: u64, +) -> Result, String> { + let encrypted = encrypt_session_statement_data( + session, + &SsoStatementData::Response { + request_id, + response_code, + }, + )?; + build_signed_statement( + session, + peer_response_channel(session), + session.session_id_peer, + encrypted, + expiry, + ) +} + /// Build a signed outbound SSO request statement with a random nonce. pub fn build_outgoing_request_statement( session: &SsoSessionInfo, @@ -992,6 +1154,187 @@ mod tests { assert_eq!(decoded, None); } + fn host_and_responder_sessions() -> (SsoSessionInfo, SsoSessionInfo) { + use crate::host_logic::sso::pairing::{ + ResponderIdentity, create_pairing_bootstrap, derive_p256_keypair_from_entropy, + establish_responder_session_info, establish_sso_session_info, + }; + use truapi_platform::{HostInfo, PairingHostConfig, PlatformInfo}; + + let config = PairingHostConfig::new( + HostInfo { + name: "Test Host".to_string(), + icon: None, + version: None, + }, + PlatformInfo::default(), + [0; 32], + [0xbb; 32], + "polkadotapp".to_string(), + ) + .expect("test pairing config is valid"); + let bootstrap = create_pairing_bootstrap(&config).unwrap(); + let statement_keypair = MiniSecretKey::from_bytes(&[7; 32]) + .unwrap() + .expand_to_keypair(ExpansionMode::Ed25519); + let (encryption_secret_key, encryption_public_key) = + derive_p256_keypair_from_entropy(&[0xAB; 16], b"sso-encryption").unwrap(); + let responder = ResponderIdentity { + statement_secret: statement_keypair.secret.to_bytes(), + statement_public_key: statement_keypair.public.to_bytes(), + encryption_secret_key, + encryption_public_key, + }; + let responder_session = establish_responder_session_info( + &responder, + bootstrap.statement_store_public_key, + bootstrap.encryption_public_key, + ) + .unwrap(); + let host_session = establish_sso_session_info( + &bootstrap, + responder.statement_public_key, + responder.encryption_public_key, + ) + .unwrap(); + (host_session, responder_session) + } + + /// A host-built request statement decodes on the responder side into the + /// batched remote messages, and the responder's ack plus response + /// statements resolve the host's pending wait. + #[test] + fn host_request_round_trips_through_responder_statements() { + let (host_session, responder_session) = host_and_responder_sessions(); + let request = sign_raw_message( + "remote-1".to_string(), + HostSignRawRequest { + account: account(), + payload: RawPayload::Payload { + payload: "hello".to_string(), + }, + }, + ); + let host_statement = build_outgoing_request_statement( + &host_session, + "statement-1".to_string(), + vec![request.clone()], + fresh_expiry(), + ) + .unwrap(); + + let incoming = decode_incoming_sso_request(&responder_session, &host_statement) + .unwrap() + .expect("responder should surface the host request"); + assert_eq!( + incoming, + IncomingSsoRequest { + request_id: "statement-1".to_string(), + messages: vec![request], + } + ); + + let ack = build_signed_session_response_statement( + &responder_session, + incoming.request_id.clone(), + 0, + fresh_expiry(), + ) + .unwrap(); + assert_eq!( + decode_sso_session_statement(&host_session, &ack, "statement-1", "remote-1").unwrap(), + Some(SsoSessionStatement::RequestAccepted) + ); + + let response = RemoteMessage { + message_id: "resp-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::SignResponse(SigningResponse { + responding_to: "remote-1".to_string(), + payload: Ok(SigningPayloadResponseData { + signature: vec![9; 64], + signed_transaction: None, + }), + })), + }; + let response_statement = build_outgoing_request_statement( + &responder_session, + "resp-statement-1".to_string(), + vec![response], + fresh_expiry(), + ) + .unwrap(); + let decoded = decode_sso_session_statement( + &host_session, + &response_statement, + "statement-1", + "remote-1", + ) + .unwrap(); + assert_eq!( + decoded, + Some(SsoSessionStatement::RemoteResponse( + SsoRemoteResponse::Sign(SigningResponse { + responding_to: "remote-1".to_string(), + payload: Ok(SigningPayloadResponseData { + signature: vec![9; 64], + signed_transaction: None, + }), + }) + )) + ); + } + + #[test] + fn responder_ignores_own_echo_and_transport_acks() { + let (host_session, responder_session) = host_and_responder_sessions(); + let own_statement = build_outgoing_request_statement( + &responder_session, + "resp-statement-1".to_string(), + vec![RemoteMessage { + message_id: "resp-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }], + fresh_expiry(), + ) + .unwrap(); + assert_eq!( + decode_incoming_sso_request(&responder_session, &own_statement).unwrap(), + None + ); + + let host_ack = build_signed_session_response_statement( + &host_session, + "resp-statement-1".to_string(), + 0, + fresh_expiry(), + ) + .unwrap(); + assert_eq!( + decode_incoming_sso_request(&responder_session, &host_ack).unwrap(), + None + ); + } + + #[test] + fn responder_ignores_expired_host_request() { + let (host_session, responder_session) = host_and_responder_sessions(); + let stale_statement = build_outgoing_request_statement( + &host_session, + "statement-1".to_string(), + vec![RemoteMessage { + message_id: "remote-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }], + elapsed_expiry(), + ) + .unwrap(); + + assert_eq!( + decode_incoming_sso_request(&responder_session, &stale_statement).unwrap(), + None + ); + } + fn response_ack_statement(session: &SsoSessionInfo, expiry: u64) -> Vec { let encrypted = encrypt_session_statement_data_with_nonce( session, diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs index cbd0204a..da820ab4 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs @@ -11,7 +11,7 @@ use super::{ CreateTransactionLegacyRequest, CreateTransactionRequest, CreateTransactionResponse, ResourceAllocationRequest, ResourceAllocationResponse, RingVrfAliasRequest, RingVrfAliasResponse, SignRawLegacyRequest, SignRawLegacyResponse, SigningRequest, - SigningResponse, + SigningResponse, StatementStoreProductSignRequest, StatementStoreProductSignResponse, }; /// v1 messages exchanged with the paired signing host over the encrypted SSO channel. @@ -32,4 +32,6 @@ pub enum RemoteMessage { CreateTransactionLegacyRequest(CreateTransactionLegacyRequest), SignRawLegacyRequest(SignRawLegacyRequest), SignRawLegacyResponse(SignRawLegacyResponse), + StatementStoreProductSignRequest(StatementStoreProductSignRequest), + StatementStoreProductSignResponse(StatementStoreProductSignResponse), } diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs index eff47058..17c18f5f 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -112,6 +112,53 @@ pub enum SsoStatementData { }, } +/// Decode a pairing deeplink (or its bare handshake hex) into the advertised +/// handshake proposal. Inverse of [`build_pairing_deeplink`]. +pub fn decode_pairing_deeplink(deeplink: &str) -> Result { + let hex_payload = match deeplink.split_once("?handshake=") { + Some((_, hex_payload)) => hex_payload, + None => deeplink, + }; + let encoded = hex::decode(hex_payload.trim()) + .map_err(|err| format!("invalid pairing deeplink hex: {err}"))?; + let mut input = encoded.as_slice(); + let proposal = VersionedHandshakeProposal::decode(&mut input) + .map_err(|err| format!("invalid pairing handshake proposal: {err}"))?; + if !input.is_empty() { + return Err("invalid pairing handshake proposal: trailing bytes".to_string()); + } + Ok(proposal) +} + +/// Encrypt a v2 handshake response for the host that advertised +/// `host_encryption_public_key`. Inverse of [`decrypt_v2_handshake_response`]: +/// a fresh ephemeral P-256 key is used per response so each Pending/Success +/// statement carries an independent ciphertext. +pub fn encrypt_v2_handshake_response( + host_encryption_public_key: [u8; 65], + response: &v2::EncryptedResponse, +) -> Result { + let (ephemeral_secret, ephemeral_public) = + generate_p256_keypair().map_err(|err| err.to_string())?; + let shared_secret = shared_secret(ephemeral_secret, host_encryption_public_key)?; + let aes_key = aes_key_from_shared_secret(&shared_secret)?; + let mut nonce = [0u8; AES_GCM_NONCE_LEN]; + getrandom::getrandom(&mut nonce) + .map_err(|err| format!("failed to generate AES-GCM nonce: {err}"))?; + let cipher = Aes256Gcm::new_from_slice(&aes_key) + .map_err(|err| format!("failed to initialize AES-GCM: {err}"))?; + let mut encrypted_message = nonce.to_vec(); + encrypted_message.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), response.encode().as_slice()) + .map_err(|err| format!("failed to encrypt SSO handshake response: {err}"))?, + ); + Ok(VersionedHandshakeResponse::V2 { + encrypted_message, + public_key: ephemeral_public, + }) +} + /// Decode wallet-posted pairing handshake data from SCALE bytes. pub fn decode_app_handshake_data(blob: &[u8]) -> Result { let mut input = blob; @@ -176,6 +223,96 @@ pub fn establish_sso_session_info( }) } +/// Signing-host key material answering one pairing proposal. +/// +/// The statement keypair signs every session statement (its public key is the +/// `identityAccountId` the pairing host binds the session to), and the P-256 +/// secret is the persistent `sso_enc` key both sides feed into the session +/// ECDH. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponderIdentity { + pub statement_secret: [u8; 64], + pub statement_public_key: [u8; 32], + pub encryption_secret_key: [u8; 32], + pub encryption_public_key: [u8; 65], +} + +/// Derive the SSO session channels from the responder (signing host) +/// perspective after answering the handshake advertised by +/// `host_statement_account_id` / `host_encryption_public_key`. +/// +/// Mirror of [`establish_sso_session_info`]: the responder's `session_id_own` +/// equals the pairing host's `session_id_peer` and vice versa, so statements +/// built from either side land on the topics the other side subscribes to. +pub fn establish_responder_session_info( + identity: &ResponderIdentity, + host_statement_account_id: [u8; 32], + host_encryption_public_key: [u8; 65], +) -> Result { + let shared_secret = shared_secret(identity.encryption_secret_key, host_encryption_public_key)?; + let shared_secret_bytes: [u8; 32] = (*shared_secret.raw_secret_bytes()).into(); + let session_id_own = create_session_id( + shared_secret_bytes, + identity.statement_public_key, + host_statement_account_id, + ); + let session_id_peer = create_session_id( + shared_secret_bytes, + host_statement_account_id, + identity.statement_public_key, + ); + + Ok(SsoSessionInfo { + ss_secret: identity.statement_secret, + ss_public_key: identity.statement_public_key, + enc_secret: identity.encryption_secret_key, + peer_enc_pubkey: host_encryption_public_key, + identity_account_id: host_statement_account_id, + session_id_own, + session_id_peer, + request_channel: keyed_hash(session_id_own, REQUEST_CHANNEL_SUFFIX), + response_channel: keyed_hash(session_id_own, RESPONSE_CHANNEL_SUFFIX), + peer_request_channel: keyed_hash(session_id_peer, REQUEST_CHANNEL_SUFFIX), + }) +} + +/// Statement channel acknowledging requests the peer initiated: the peer's +/// own `response` channel seen from this side of the session. +pub fn peer_response_channel(session: &SsoSessionInfo) -> [u8; 32] { + keyed_hash(session.session_id_peer, RESPONSE_CHANNEL_SUFFIX) +} + +/// Derive a deterministic P-256 keypair from BIP-39 entropy and a domain +/// label. Host-spec C.4 leaves signing-host P-256 derivation +/// implementation-defined; this scheme only needs to be stable per entropy. +pub fn derive_p256_keypair_from_entropy( + entropy: &[u8], + label: &[u8], +) -> Result<([u8; 32], [u8; 65]), PairingBootstrapError> { + for attempt in 0..MAX_P256_SECRET_ATTEMPTS { + let mut message = Vec::with_capacity(label.len() + 1); + message.extend_from_slice(label); + message.push(attempt as u8); + let candidate: [u8; 32] = blake2b(32, entropy, &message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes"); + let Ok(secret) = SecretKey::from_slice(&candidate) else { + continue; + }; + let public = secret.public_key().to_encoded_point(false); + let public = public.as_bytes(); + if public.len() != 65 { + return Err(PairingBootstrapError::InvalidP256Secret); + } + let mut encryption_public_key = [0u8; 65]; + encryption_public_key.copy_from_slice(public); + return Ok((candidate, encryption_public_key)); + } + + Err(PairingBootstrapError::InvalidP256Secret) +} + /// Encrypt session-channel statement data with a random nonce. pub fn encrypt_session_statement_data( session: &SsoSessionInfo, @@ -310,7 +447,10 @@ fn create_session_id( } fn keyed_hash(key: [u8; 32], message: &[u8]) -> [u8; 32] { - let digest = blake2b(32, &key, message); + let digest = blake2b_simd::Params::new() + .hash_length(32) + .key(&key) + .hash(message); let mut output = [0u8; 32]; output.copy_from_slice(digest.as_bytes()); output @@ -486,6 +626,7 @@ mod tests { version: Some("192.32".to_string()), }, [0; 32], + [0xbb; 32], "polkadotapp".to_string(), ) .expect("test runtime config is valid") @@ -663,6 +804,136 @@ mod tests { ); } + #[test] + fn decodes_pairing_deeplink_round_trip() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + + let decoded = decode_pairing_deeplink(&deeplink).unwrap(); + + let VersionedHandshakeProposal::V2(proposal) = decoded; + assert_eq!(proposal.device.statement_account_id, SS_PUBLIC); + assert_eq!(proposal.device.encryption_public_key, ENC_PUBLIC); + } + + #[test] + fn decodes_bare_handshake_hex() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + let hex_payload = deeplink.split("handshake=").nth(1).unwrap(); + + assert_eq!( + decode_pairing_deeplink(hex_payload).unwrap(), + decode_pairing_deeplink(&deeplink).unwrap() + ); + } + + #[test] + fn rejects_pairing_deeplink_with_trailing_bytes() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + + let err = decode_pairing_deeplink(&format!("{deeplink}00")).unwrap_err(); + + assert_eq!(err, "invalid pairing handshake proposal: trailing bytes"); + } + + #[test] + fn encrypted_handshake_response_round_trips_through_host_decrypt() { + let host_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let host_public: [u8; 65] = host_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(); + let response = v2::EncryptedResponse::Success(Box::new(v2::Success { + identity_account_id: [8; 32], + root_account_id: [7; 32], + identity_chat_private_key: [6; 32], + sso_enc_pub_key: ENC_PUBLIC, + device_enc_pub_key: ENC_PUBLIC, + root_entropy_source: [5; 32], + })); + + let VersionedHandshakeResponse::V2 { + encrypted_message, + public_key, + } = encrypt_v2_handshake_response(host_public, &response).unwrap(); + + assert_eq!( + decrypt_v2_handshake_response( + host_secret.to_bytes().into(), + public_key, + &encrypted_message + ) + .unwrap(), + response + ); + } + + fn responder_identity() -> ResponderIdentity { + let (statement_secret, statement_public_key) = generate_statement_store_keypair().unwrap(); + let (encryption_secret_key, encryption_public_key) = + derive_p256_keypair_from_entropy(&[0xAB; 16], b"sso-encryption").unwrap(); + ResponderIdentity { + statement_secret, + statement_public_key, + encryption_secret_key, + encryption_public_key, + } + } + + /// The responder-perspective session must mirror the host-perspective + /// session: swapped session ids, aligned channels, and one shared AES key + /// so either side can decrypt the other's statement data. + #[test] + fn responder_session_mirrors_host_session() { + let config = runtime_config(); + let host_bootstrap = create_pairing_bootstrap(&config).unwrap(); + let responder = responder_identity(); + + let responder_session = establish_responder_session_info( + &responder, + host_bootstrap.statement_store_public_key, + host_bootstrap.encryption_public_key, + ) + .unwrap(); + let host_session = establish_sso_session_info( + &host_bootstrap, + responder.statement_public_key, + responder.encryption_public_key, + ) + .unwrap(); + + assert_eq!( + responder_session.session_id_own, + host_session.session_id_peer + ); + assert_eq!( + responder_session.session_id_peer, + host_session.session_id_own + ); + assert_eq!( + responder_session.request_channel, + host_session.peer_request_channel + ); + assert_eq!( + peer_response_channel(&responder_session), + host_session.response_channel + ); + + let data = SsoStatementData::Request { + request_id: "req-1".to_string(), + data: vec![vec![0xde, 0xad]], + }; + let encrypted = encrypt_session_statement_data(&responder_session, &data).unwrap(); + assert_eq!( + decrypt_session_statement_data(&host_session, &encrypted).unwrap(), + data + ); + } + #[test] fn statement_data_codec_round_trips_request_and_response() { let request = SsoStatementData::Request { diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs index 1340cd3a..f7dd7021 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs @@ -1,8 +1,9 @@ use parity_scale_codec::{Compact, Decode, Encode}; -use schnorrkel::{PublicKey, SecretKey, Signature}; +use schnorrkel::{PublicKey, Signature}; use truapi::v01; use super::StatementStoreParseError; +use crate::host_logic::extrinsic::sr25519_secret_from_bytes; use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; use crate::host_logic::session::SsoSessionInfo; @@ -175,7 +176,8 @@ pub fn sign_statement_fields( } fields.sort_by_key(statement_field_sort_index); - let secret = statement_secret_key_from_bytes(ss_secret)?; + let secret = sr25519_secret_from_bytes(&ss_secret) + .map_err(|reason| format!("invalid ss_secret: {reason}"))?; let public = secret.to_public(); if public.to_bytes() != expected_public_key { return Err("ss_secret does not match session statement public key".to_string()); @@ -197,21 +199,11 @@ pub fn sign_statement_fields( /// Derive the sr25519 public key for a 64-byte statement-store secret. pub fn statement_public_key_from_secret(ss_secret: [u8; 64]) -> Result<[u8; 32], String> { - let secret = statement_secret_key_from_bytes(ss_secret)?; + let secret = sr25519_secret_from_bytes(&ss_secret) + .map_err(|reason| format!("invalid ss_secret: {reason}"))?; Ok(secret.to_public().to_bytes()) } -fn statement_secret_key_from_bytes(ss_secret: [u8; 64]) -> Result { - // Rust-generated session keys use schnorrkel's canonical scalar bytes. - // Legacy JS signers may send scure/ed25519-style scalar bytes instead. - match SecretKey::from_bytes(&ss_secret) { - Ok(secret) => Ok(secret), - Err(canonical_error) => SecretKey::from_ed25519_bytes(&ss_secret).map_err(|ed_error| { - format!("invalid ss_secret: canonical={canonical_error}; ed25519={ed_error}") - }), - } -} - /// Build the statement proof payload for unsigned fields. pub fn unsigned_statement_signing_payload( mut fields: Vec, diff --git a/rust/crates/truapi-server/src/host_logic/transaction.rs b/rust/crates/truapi-server/src/host_logic/transaction.rs new file mode 100644 index 00000000..55522a8a --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/transaction.rs @@ -0,0 +1,207 @@ +//! Extrinsic signing preimages and v4 signed-extrinsic assembly. +//! +//! Signing hosts receive pre-encoded payload fields (`HostSignPayloadData`) +//! or pre-encoded transaction extensions (`TxPayloadExtension`), so no chain +//! metadata is needed: the preimage is a byte concatenation and the signed +//! extrinsic is assembled mechanically. Matches the polkadot-app signing +//! convention: preimages longer than 256 bytes are BLAKE2b-256 hashed before +//! signing. + +use blake2_rfc::blake2b::blake2b; +use parity_scale_codec::{Compact, Encode}; +use truapi::v01::{HostSignPayloadData, TxPayloadExtension}; + +/// Preimages longer than this are hashed before signing (standard Substrate +/// signed-payload rule). +const MAX_SIGNED_PREIMAGE_LEN: usize = 256; + +/// Extrinsic version 4 with the signed bit set. +const EXTRINSIC_V4_SIGNED: u8 = 0x84; +/// `MultiAddress::Id` variant index. +const MULTI_ADDRESS_ID: u8 = 0x00; +/// `MultiSignature::Sr25519` variant index. +const MULTI_SIGNATURE_SR25519: u8 = 0x01; + +/// Signing preimage for an extrinsic payload assembled from pre-encoded +/// fields, in the polkadot-app field order. Empty optional fields are +/// skipped, mirroring the JS falsy-field rule. +pub fn extrinsic_payload_preimage(payload: &HostSignPayloadData) -> Vec { + let parts: [&[u8]; 8] = [ + &payload.method, + &payload.era, + &payload.nonce, + &payload.tip, + &payload.spec_version, + &payload.transaction_version, + &payload.genesis_hash, + &payload.block_hash, + ]; + let mut preimage = Vec::new(); + for part in parts { + preimage.extend_from_slice(part); + } + if let Some(asset_id) = &payload.asset_id { + preimage.extend_from_slice(asset_id); + } + if let Some(metadata_hash) = &payload.metadata_hash { + preimage.extend_from_slice(metadata_hash); + } + hash_large_preimage(preimage) +} + +/// Signing preimage for a transaction built from pre-encoded extensions: +/// call data, then every extension's `extra`, then every extension's +/// `additional_signed`. +pub fn transaction_signing_preimage( + call_data: &[u8], + extensions: &[TxPayloadExtension], +) -> Vec { + let mut preimage = call_data.to_vec(); + for extension in extensions { + preimage.extend_from_slice(&extension.extra); + } + for extension in extensions { + preimage.extend_from_slice(&extension.additional_signed); + } + hash_large_preimage(preimage) +} + +/// Assemble a v4 signed extrinsic from a signer public key, an sr25519 +/// signature over [`transaction_signing_preimage`], the pre-encoded +/// extension `extra` data, and the call data. +pub fn build_v4_signed_extrinsic( + signer_public_key: [u8; 32], + signature: [u8; 64], + extensions: &[TxPayloadExtension], + call_data: &[u8], +) -> Vec { + let mut body = Vec::with_capacity(2 + 32 + 1 + 64 + call_data.len()); + body.push(EXTRINSIC_V4_SIGNED); + body.push(MULTI_ADDRESS_ID); + body.extend_from_slice(&signer_public_key); + body.push(MULTI_SIGNATURE_SR25519); + body.extend_from_slice(&signature); + for extension in extensions { + body.extend_from_slice(&extension.extra); + } + body.extend_from_slice(call_data); + + let mut extrinsic = Compact(body.len() as u32).encode(); + extrinsic.extend_from_slice(&body); + extrinsic +} + +fn hash_large_preimage(preimage: Vec) -> Vec { + if preimage.len() > MAX_SIGNED_PREIMAGE_LEN { + blake2b(32, &[], &preimage).as_bytes().to_vec() + } else { + preimage + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn payload() -> HostSignPayloadData { + HostSignPayloadData { + block_hash: vec![0xB1, 0xB2], + block_number: vec![0xFF], + era: vec![0xE1], + genesis_hash: vec![0x61, 0x62], + method: vec![0x4D], + nonce: vec![0x4E], + spec_version: vec![0x51], + tip: vec![0x54], + transaction_version: vec![0x56], + signed_extensions: vec![], + version: 4, + asset_id: None, + metadata_hash: None, + mode: None, + with_signed_transaction: None, + } + } + + #[test] + fn payload_preimage_uses_polkadot_app_field_order() { + // method, era, nonce, tip, spec_version, transaction_version, + // genesis_hash, block_hash. block_number is not part of the preimage. + assert_eq!( + extrinsic_payload_preimage(&payload()), + vec![0x4D, 0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2] + ); + } + + #[test] + fn payload_preimage_appends_asset_id_and_metadata_hash() { + let mut payload = payload(); + payload.asset_id = Some(vec![0xAA]); + payload.metadata_hash = Some(vec![0xBB]); + + assert_eq!( + extrinsic_payload_preimage(&payload), + vec![ + 0x4D, 0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2, 0xAA, 0xBB + ] + ); + } + + #[test] + fn long_preimages_are_blake2b_hashed() { + let mut payload = payload(); + payload.method = vec![0x4D; 300]; + + let preimage = extrinsic_payload_preimage(&payload); + + assert_eq!(preimage.len(), 32); + let mut raw = vec![0x4D; 300]; + raw.extend_from_slice(&[0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2]); + assert_eq!(preimage, blake2b(32, &[], &raw).as_bytes().to_vec()); + } + + #[test] + fn transaction_preimage_orders_call_extra_then_implicit() { + let extensions = vec![ + TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![0x01], + additional_signed: vec![0x02], + }, + TxPayloadExtension { + id: "CheckSpecVersion".to_string(), + extra: vec![0x03], + additional_signed: vec![0x04], + }, + ]; + + assert_eq!( + transaction_signing_preimage(&[0xCA, 0x11], &extensions), + vec![0xCA, 0x11, 0x01, 0x03, 0x02, 0x04] + ); + } + + #[test] + fn builds_v4_signed_extrinsic_layout() { + let extensions = vec![TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![0xEE], + additional_signed: vec![0xDD], + }]; + + let extrinsic = + build_v4_signed_extrinsic([0xAB; 32], [0xCD; 64], &extensions, &[0xCA, 0x11]); + + let body_len = 1 + 1 + 32 + 1 + 64 + 1 + 2; + assert_eq!(extrinsic[..2], Compact(body_len as u32).encode()[..]); + let body = &extrinsic[2..]; + assert_eq!(body.len(), body_len); + assert_eq!(body[0], 0x84); + assert_eq!(body[1], 0x00); + assert_eq!(&body[2..34], &[0xAB; 32]); + assert_eq!(body[34], 0x01); + assert_eq!(&body[35..99], &[0xCD; 64]); + assert_eq!(body[99], 0xEE); + assert_eq!(&body[100..], &[0xCA, 0x11]); + } +} diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index 8fa63e23..3d890b94 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -4,6 +4,9 @@ //! implementation, then create product-scoped [`ProductRuntime`] endpoints that //! expose the stable byte-frame API used from WASM, native mobile, or desktop //! shells. +//! +//! Host-facing bridges: +//! - `wasm` (wasm32 only): wasm-bindgen surface exposing `WasmProductRuntime`. pub(crate) mod chain_runtime; pub mod core; @@ -22,11 +25,18 @@ pub(crate) mod test_support; pub mod generated; +#[cfg(target_arch = "wasm32")] +pub mod wasm; + pub use host_core::{ FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeError, SigningHostRuntime, }; +pub use runtime::ResponderExit; pub use truapi_platform::{ HostRuntimeConfig, PairingHostConfig, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, ProductContext, SigningHostConfig, }; + +#[cfg(target_arch = "wasm32")] +pub use wasm::*; diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 7004a228..02614d4f 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -10,21 +10,25 @@ mod allowances; pub(crate) mod auth_state; mod authority; +pub(crate) mod bulletin_rpc; mod identity; mod pairing_host; pub(crate) mod services; mod signing_host; pub(crate) mod sso_pairing; pub(crate) mod sso_remote; +#[cfg(not(target_arch = "wasm32"))] +mod statement_allowance; pub(crate) mod statement_store; mod statement_store_rpc; use core::future::Future; use core::time::Duration; -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use crate::chain_runtime::RuntimeFailure; -use crate::host_logic::allowance_signer::bulletin_allowance_signer_from_key; +use crate::host_logic::bulletin::preimage_key; use crate::host_logic::dotns::{NavigateDecision, parse_navigate}; use crate::host_logic::features::feature_supported; use crate::host_logic::permissions::PermissionsService; @@ -34,6 +38,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; @@ -41,7 +46,10 @@ pub(crate) use authority::ProductAuthority; use pairing_host::PairingHost; pub(crate) use pairing_host::PairingHost as PairingHostRole; pub(crate) use services::RuntimeServices; -pub(crate) use signing_host::{LocalActivation, SigningHost as SigningHostRole}; +pub use signing_host::ResponderExit; +pub(crate) use signing_host::{ + LocalActivation, SigningHost as SigningHostRole, respond_to_pairing, +}; use authority::{ AuthorityCancelError, AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, @@ -145,6 +153,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(); @@ -207,6 +219,8 @@ pub struct ProductRuntimeHost { /// Stable per-product-runtime id used to scope long-lived chain follow /// operation ids within one shared host runtime. core_instance: u64, + /// Host-owned transaction operation ids mapped to provider operation ids. + transaction_operations: Mutex>, } impl ProductRuntimeHost { @@ -222,6 +236,7 @@ impl ProductRuntimeHost { authority, product, core_instance, + transaction_operations: Mutex::new(HashMap::new()), } } @@ -247,11 +262,8 @@ impl ProductRuntimeHost { } #[cfg(test)] - fn new_compat_with_pairing( - platform: Arc, - spawner: Spawner, - ) -> (Self, Arc) { - let host_config = truapi_platform::PairingHostConfig::new( + fn compat_host_config() -> truapi_platform::PairingHostConfig { + truapi_platform::PairingHostConfig::new( truapi_platform::HostInfo { name: "Polkadot Web".to_string(), icon: Some("https://example.invalid/dotli.png".to_string()), @@ -259,9 +271,31 @@ impl ProductRuntimeHost { }, truapi_platform::PlatformInfo::default(), [0; 32], + [0xbb; 32], "polkadotapp".to_string(), ) - .expect("compat runtime config is valid"); + .expect("compat runtime config is valid") + } + + /// Compat host used by preimage tests. + #[cfg(test)] + fn new_compat_with_bulletin(platform: Arc, spawner: Spawner) -> Self { + Self::new_pairing_for_tests( + platform, + Self::compat_host_config(), + ProductContext::new("unknown.dot".to_string()) + .expect("compat product context is valid"), + spawner, + ) + .0 + } + + #[cfg(test)] + fn new_compat_with_pairing( + platform: Arc, + spawner: Spawner, + ) -> (Self, Arc) { + let host_config = Self::compat_host_config(); Self::new_pairing_for_tests( platform, host_config, @@ -281,6 +315,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); @@ -290,6 +325,7 @@ impl ProductRuntimeHost { authority: pairing_host.clone(), product, core_instance, + transaction_operations: Mutex::new(HashMap::new()), }; (host, pairing_host) } @@ -346,6 +382,16 @@ impl ProductRuntimeHost { fn follow_id(&self, id: &str) -> String { format!("c{}:{id}", self.core_instance) } + + fn transaction_id(&self) -> String { + format!("c{}:tx:{}", self.core_instance, nanoid::nanoid!(10)) + } +} + +#[derive(Debug, Clone)] +struct TransactionOperation { + genesis_hash: Vec, + provider_operation_id: String, } impl ProductRuntimeHost { @@ -891,24 +937,22 @@ impl Account for ProductRuntimeHost { Err(reason) => return Err(CallError::HostFailure { reason }), } - let primary_username = session - .full_username - .clone() - .filter(|value| !value.is_empty()) - .or_else(|| { - session - .lite_username - .clone() - .filter(|value| !value.is_empty()) - }) - .ok_or_else(|| { - CallError::Domain(HostGetUserIdError::V1(v01::HostGetUserIdError::Unknown { - reason: "No primary username for this session".to_string(), - })) - })?; + let session = if session.primary_username().is_some() { + session + } else { + self.authority + .refresh_session_identity() + .await + .unwrap_or(session) + }; + let primary_username = session.primary_username().ok_or_else(|| { + CallError::Domain(HostGetUserIdError::V1(v01::HostGetUserIdError::Unknown { + reason: "No primary username for this session".to_string(), + })) + })?; Ok(HostGetUserIdResponse::V1(v01::HostGetUserIdResponse { - primary_username, + primary_username: primary_username.to_string(), })) } @@ -947,9 +991,9 @@ fn account_get_error_from_authority(err: AuthorityError) -> v01::HostAccountGetE AuthorityError::Cancelled(err) => v01::HostAccountGetError::Unknown { reason: err.to_string(), }, - AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { - v01::HostAccountGetError::Unknown { reason } - } + AuthorityError::Unavailable { reason } + | AuthorityError::NotSupported { reason } + | AuthorityError::Unknown { reason } => v01::HostAccountGetError::Unknown { reason }, } } @@ -964,9 +1008,9 @@ fn signing_call_error( AuthorityError::Cancelled(err) => v01::HostSignPayloadError::Unknown { reason: err.to_string(), }, - AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { - v01::HostSignPayloadError::Unknown { reason } - } + AuthorityError::Unavailable { reason } + | AuthorityError::NotSupported { reason } + | AuthorityError::Unknown { reason } => v01::HostSignPayloadError::Unknown { reason }, })) } @@ -981,6 +1025,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 } } @@ -1534,12 +1581,34 @@ impl Chain for ProductRuntimeHost { }, )) .await?; - self.services + let genesis_hash = inner.genesis_hash.clone(); + let response = self + .services .chain .remote_chain_transaction_broadcast(inner) .await - .map(RemoteChainTransactionBroadcastResponse::V1) - .map_err(runtime_failure_to_call_error) + .map_err(runtime_failure_to_call_error)?; + let Some(provider_operation_id) = response.operation_id else { + return Ok(RemoteChainTransactionBroadcastResponse::V1( + v01::RemoteChainTransactionBroadcastResponse { operation_id: None }, + )); + }; + let operation_id = self.transaction_id(); + self.transaction_operations + .lock() + .expect("transaction operations mutex poisoned") + .insert( + operation_id.clone(), + TransactionOperation { + genesis_hash, + provider_operation_id, + }, + ); + Ok(RemoteChainTransactionBroadcastResponse::V1( + v01::RemoteChainTransactionBroadcastResponse { + operation_id: Some(operation_id), + }, + )) } #[instrument(skip_all, fields(runtime.method = "chain.stop_transaction"))] @@ -1550,18 +1619,58 @@ impl Chain for ProductRuntimeHost { ) -> Result> { let RemoteChainTransactionStopRequest::V1(inner) = request; - // We intentionally forward the provider operation id here. Transaction - // operation ids are node-assigned and short-lived, so cross-product - // collision or guessing is not worth local id indirection yet. - self.services + let operation = { + let operations = self + .transaction_operations + .lock() + .expect("transaction operations mutex poisoned"); + let Some(operation) = operations.get(&inner.operation_id).cloned() else { + return Err(CallError::HostFailure { + reason: format!("unknown transaction operation id: {}", inner.operation_id), + }); + }; + if operation.genesis_hash != inner.genesis_hash { + return Err(CallError::HostFailure { + reason: "transaction operation id belongs to a different chain".to_string(), + }); + } + operation + }; + let stop_request = v01::RemoteChainTransactionStopRequest { + genesis_hash: operation.genesis_hash, + operation_id: operation.provider_operation_id, + }; + let result = self + .services .chain - .remote_chain_transaction_stop(inner) + .remote_chain_transaction_stop(stop_request) .await + .or_else(|failure| { + if transaction_operation_already_finished(&failure) { + Ok(()) + } else { + Err(failure) + } + }) .map(|()| RemoteChainTransactionStopResponse::V1) - .map_err(runtime_failure_to_call_error) + .map_err(runtime_failure_to_call_error); + if result.is_ok() { + self.transaction_operations + .lock() + .expect("transaction operations mutex poisoned") + .remove(&inner.operation_id); + } + result } } +fn transaction_operation_already_finished(failure: &RuntimeFailure) -> bool { + failure + .reason() + .to_ascii_lowercase() + .contains("invalid operation id") +} + // --------------------------------------------------------------------------- // Deferred product surfaces. // @@ -1732,19 +1841,53 @@ impl Preimage for ProductRuntimeHost { let RemotePreimageLookupSubscribeRequest::V1(v01::RemotePreimageLookupSubscribeRequest { key, }) = request; + + // A cache hit is final: preimages are content-addressed and immutable. + // Emit the value once, then keep the subscription open (never complete, + // which would emit a product-visible interrupt frame) until the caller + // unsubscribes. + if let Ok(key_bytes) = <[u8; 32]>::try_from(key.as_slice()) + && let Some(value) = self.services.cached_preimage(&key_bytes) + { + let item = + RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { + value: Some(value), + }); + let stream = + futures::stream::once(async move { item }).chain(futures::stream::pending()); + return Subscription::new(Box::pin(stream)); + } + + // Otherwise delegate to the host content backend, verifying that any + // returned value hashes to the requested key so a compromised backend + // cannot feed products forged content. A mismatch is downgraded to a + // miss (the wire item has no error channel and the product still needs + // its initial current-value/miss emission). let stream = self .services .platform - .lookup_preimage(key) - .filter_map(|item| async move { - // TODO: preserve platform stream errors as terminal - // subscription interrupts once subscription items can carry - // in-stream failures. - item.ok().map(|value| { - RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { - value, - }) - }) + .lookup_preimage(key.clone()) + .filter_map(move |item| { + let key = key.clone(); + async move { + // TODO: preserve platform stream errors as terminal + // subscription interrupts once subscription items can carry + // in-stream failures. + let value = item.ok()?; + let value = value.filter(|value| { + let matches = preimage_key(value)[..] == key[..]; + if !matches { + tracing::warn!( + "preimage lookup returned a value whose hash does not match the \ + requested key; downgrading to a miss" + ); + } + matches + }); + Some(RemotePreimageLookupSubscribeItem::V1( + v01::RemotePreimageLookupSubscribeItem { value }, + )) + } }); Subscription::new(Box::pin(stream)) } @@ -1757,12 +1900,9 @@ impl Preimage for ProductRuntimeHost { ) -> Result> { let RemotePreimageSubmitRequest::V1(value) = request; let Some(session) = self.authority.current_session() else { - return Err(CallError::Domain(RemotePreimageSubmitError::V1( - v01::PreimageSubmitError::Unknown { - reason: "No active session".to_string(), - }, - ))); + return Err(preimage_submit_error("No active session".to_string())); }; + let bulletin = &self.services.bulletin; self.require_remote_permission( v01::RemotePermission::PreimageSubmit, RemotePreimageSubmitError::V1(v01::PreimageSubmitError::Unknown { @@ -1779,46 +1919,72 @@ impl Preimage for ProductRuntimeHost { }, )) .await - .map_err(|err| { - CallError::Domain(RemotePreimageSubmitError::V1( - v01::PreimageSubmitError::Unknown { reason: err.reason }, - )) - })?; + .map_err(|err| preimage_submit_error(err.reason))?; if !confirmed { - return Err(CallError::Domain(RemotePreimageSubmitError::V1( - v01::PreimageSubmitError::Unknown { - reason: "User rejected preimage submission".to_string(), - }, - ))); + return Err(preimage_submit_error( + "User rejected preimage submission".to_string(), + )); } - let cx = remote_authority_context(cx); + + let authority_cx = remote_authority_context(cx); let allowance = remote_authority_call( - &cx, + &authority_cx, self.authority - .bulletin_allowance_key(&cx, &session, self.product_id()), + .bulletin_allowance_key(&authority_cx, &session, self.product_id()), ) .await - .map_err(|err| { - CallError::Domain(RemotePreimageSubmitError::V1( - v01::PreimageSubmitError::Unknown { - reason: err.reason(), - }, - )) - })?; - let signer = bulletin_allowance_signer_from_key(allowance).map_err(|reason| { - CallError::Domain(RemotePreimageSubmitError::V1( - v01::PreimageSubmitError::Unknown { reason }, - )) - })?; - self.services - .platform - .submit_preimage(value, signer) + .map_err(|err| preimage_submit_error(err.reason()))?; + + let key = match bulletin + .submit_preimage(cx, PREIMAGE_SUBMIT_BUDGET, &allowance, &value) .await - .map(RemotePreimageSubmitResponse::V1) - .map_err(|err| CallError::Domain(RemotePreimageSubmitError::V1(err))) + { + Ok(key) => key, + // A rejected allowance is the one case a refresh-and-retry can fix: + // evict the exhausted key, allocate a fresh (increased) allowance, + // and try exactly once more. + Err(BulletinSubmitError::AllowanceRejected { .. }) => { + let allowance = remote_authority_call( + &authority_cx, + self.authority.refresh_bulletin_allowance_key( + &authority_cx, + &session, + self.product_id(), + ), + ) + .await + .map_err(|err| preimage_submit_error(err.reason()))?; + bulletin + .submit_preimage(cx, PREIMAGE_SUBMIT_BUDGET, &allowance, &value) + .await + .map_err(|err| preimage_submit_error(err.reason()))? + } + Err(err) => return Err(preimage_submit_error(err.reason())), + }; + // Move the owned body into the lookup cache (no extra copy) so an + // immediate product lookup hits before the content backend has it. + self.prime_preimage_cache(&key, value); + Ok(RemotePreimageSubmitResponse::V1(key)) } } +impl ProductRuntimeHost { + /// Cache a just-submitted preimage under its key for immediate lookups. + fn prime_preimage_cache(&self, key: &[u8], value: Vec) { + if let Ok(key_bytes) = <[u8; 32]>::try_from(key) { + debug_assert_eq!(key_bytes, preimage_key(&value)); + self.services.cache_preimage(key_bytes, value); + } + } +} + +/// Build the product-facing `Unknown` wire error carrying `reason`. +fn preimage_submit_error(reason: String) -> CallError { + CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { reason }, + )) +} + // --------------------------------------------------------------------------- // Theme // --------------------------------------------------------------------------- @@ -1892,10 +2058,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 +2113,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 +2915,9 @@ mod tests { } } - fn bulletin_slot_account_key_fixture() -> Vec { - hex::decode( - "0eef5183411d40c32446bb1cbaabd70004a17af6012a577c735d054f04059208\ - 573dfc9b6ffeb1c786a16349e70f9836876a743c31c0a7a2a70727a852eec372", - ) - .unwrap() - } - - fn expected_bulletin_slot_account_public_key() -> Vec { - hex::decode("10c68432943c68a6e1be650818b5e08db79e57823de9f34df7ba36d404d91e1d").unwrap() - } - - #[test] - fn preimage_submit_confirms_and_delegates_to_platform() { - let session = sso_session_info(); - let slot_account_key = bulletin_slot_account_key_fixture(); - let preimage_submit_allowance_public_keys = Arc::new(Mutex::new(Vec::new())); - let preimage_submit_signatures = Arc::new(Mutex::new(Vec::new())); - let platform = Arc::new(StubPlatform { - preimage_submit_allowance_public_keys: preimage_submit_allowance_public_keys.clone(), - preimage_submit_signatures: preimage_submit_signatures.clone(), - sso_response_script: Some(sso_success_response_script( - &session, - RemoteMessage { - message_id: "wallet-preimage-allowance".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse( - ResourceAllocationResponse { - responding_to: "preimage-submit".to_string(), - payload: Ok(vec![SsoAllocationOutcome::Allocated( - SsoAllocatedResource::BulletinAllowance { - slot_account_key: slot_account_key.clone(), - }, - )]), - }, - )), - }, - )), - ..Default::default() - }); - let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); - host.test_session_state().set_session(session.clone()); - let cx = CallContext::new(); - let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); - let response = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap(); - assert_eq!(response, RemotePreimageSubmitResponse::V1(vec![1, 2, 3])); - assert_eq!( - preimage_submit_allowance_public_keys - .lock() - .expect("preimage allowance public key list mutex poisoned") - .as_slice(), - &[expected_bulletin_slot_account_public_key()] - ); - assert_eq!( - preimage_submit_signatures - .lock() - .expect("preimage allowance signature list mutex poisoned")[0] - .len(), - 64 - ); - let message = submitted_remote_message(&platform, &session); - match message.data { - RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationRequest(request)) => { - assert_eq!( - request.resources, - vec![SsoAllocatableResource::BulletinAllowance] - ); - assert_eq!(request.on_existing, OnExistingAllowancePolicy::Ignore); - } - other => panic!("expected bulletin allowance request, got {other:?}"), - } - } - - #[test] - fn preimage_submit_uses_persisted_bulletin_allowance_key() { - let session = sso_session_info(); - let slot_account_key = bulletin_slot_account_key_fixture(); - let preimage_submit_allowance_public_keys = Arc::new(Mutex::new(Vec::new())); - let preimage_submit_signatures = Arc::new(Mutex::new(Vec::new())); - let platform = Arc::new(StubPlatform { - preimage_submit_allowance_public_keys: preimage_submit_allowance_public_keys.clone(), - preimage_submit_signatures: preimage_submit_signatures.clone(), - ..Default::default() - }); - futures::executor::block_on(allowances::write_allowance_key( - &*platform, - &session, - "unknown.dot", - allowances::AllowanceResource::Bulletin, - slot_account_key.clone(), - )) - .unwrap(); - let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); - host.test_session_state().set_session(session); - let cx = CallContext::new(); - let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); - let response = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap(); - assert_eq!(response, RemotePreimageSubmitResponse::V1(vec![1, 2, 3])); - assert_eq!( - preimage_submit_allowance_public_keys - .lock() - .expect("preimage allowance public key list mutex poisoned") - .as_slice(), - &[expected_bulletin_slot_account_public_key()] - ); - assert_eq!( - preimage_submit_signatures - .lock() - .expect("preimage allowance signature list mutex poisoned")[0] - .len(), - 64 - ); - assert!( - platform - .sent_rpc - .lock() - .expect("rpc list mutex poisoned") - .is_empty(), - "persisted allowance should not send an SSO resource-allocation request" - ); - } - #[test] - fn preimage_submit_requires_session_before_backend_call() { - let preimage_submits = Arc::new(Mutex::new(Vec::new())); - let host = ProductRuntimeHost::new_compat( - Arc::new(StubPlatform { - preimage_submits: preimage_submits.clone(), - ..Default::default() - }), - test_spawner(), - ); + fn preimage_submit_requires_session_first() { + let host = ProductRuntimeHost::new_compat_with_bulletin(stub_platform(), test_spawner()); let cx = CallContext::new(); let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); @@ -2893,25 +2929,15 @@ mod tests { )) => assert_eq!(reason, "No active session"), other => panic!("expected preimage session error, got {other:?}"), } - assert!( - preimage_submits - .lock() - .expect("preimage submit list mutex poisoned") - .is_empty() - ); } #[test] fn preimage_submit_requires_remote_permission_before_backend_call() { - let preimage_submits = Arc::new(Mutex::new(Vec::new())); - let host = ProductRuntimeHost::new_compat( - Arc::new(StubPlatform { - remote_permission_denied: true, - preimage_submits: preimage_submits.clone(), - ..Default::default() - }), - test_spawner(), - ); + let platform = Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat_with_bulletin(platform.clone(), test_spawner()); host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); @@ -2923,9 +2949,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 +2987,220 @@ mod tests { } #[test] - fn preimage_lookup_subscribe_maps_platform_values() { + fn chain_stop_transaction_uses_host_operation_id_and_accepts_finished_remote_op() { + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":"remote-op"}"#.to_string(), + r#"{"jsonrpc":"2.0","id":"truapi:2","error":{"code":-32602,"message":"Invalid operation id"}}"#.to_string(), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::new(); + let genesis_hash = vec![7u8; 32]; + let broadcast = futures::executor::block_on(Chain::broadcast_transaction( + &host, + &cx, + RemoteChainTransactionBroadcastRequest::V1( + v01::RemoteChainTransactionBroadcastRequest { + genesis_hash: genesis_hash.clone(), + transaction: vec![1, 2, 3], + }, + ), + )) + .unwrap(); + let RemoteChainTransactionBroadcastResponse::V1(broadcast) = broadcast; + let operation_id = broadcast.operation_id.expect("host operation id"); + assert!(operation_id.starts_with("c")); + assert!(operation_id.contains(":tx:")); + assert_ne!(operation_id, "remote-op"); + + futures::executor::block_on(Chain::stop_transaction( + &host, + &cx, + RemoteChainTransactionStopRequest::V1(v01::RemoteChainTransactionStopRequest { + genesis_hash, + operation_id: operation_id.clone(), + }), + )) + .expect("finished provider operation should count as stopped"); + + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + let stop_request: serde_json::Value = serde_json::from_str( + sent.iter() + .find(|request| request.contains("transaction_v1_stop")) + .expect("remote stop request sent"), + ) + .unwrap(); + assert_eq!(stop_request["params"][0], "remote-op"); + assert_ne!(stop_request["params"][0], operation_id); + } + + #[test] + fn chain_stop_transaction_rejects_unknown_host_operation_id() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::new(); + let err = futures::executor::block_on(Chain::stop_transaction( + &host, + &cx, + RemoteChainTransactionStopRequest::V1(v01::RemoteChainTransactionStopRequest { + genesis_hash: vec![7u8; 32], + operation_id: "missing-op".to_string(), + }), + )) + .unwrap_err(); + assert!( + matches!(err, CallError::HostFailure { ref reason } if reason.contains("unknown transaction operation id")), + "unexpected error: {err:?}", + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } + + #[test] + fn chain_stop_transaction_keeps_operation_retryable_after_remote_failure() { + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":"remote-op"}"#.to_string(), + r#"{"jsonrpc":"2.0","id":"truapi:2","error":{"code":-32000,"message":"temporary stop failure"}}"#.to_string(), + r#"{"jsonrpc":"2.0","id":"truapi:3","result":null}"#.to_string(), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::new(); + let genesis_hash = vec![7u8; 32]; + let broadcast = futures::executor::block_on(Chain::broadcast_transaction( + &host, + &cx, + RemoteChainTransactionBroadcastRequest::V1( + v01::RemoteChainTransactionBroadcastRequest { + genesis_hash: genesis_hash.clone(), + transaction: vec![1, 2, 3], + }, + ), + )) + .unwrap(); + let RemoteChainTransactionBroadcastResponse::V1(broadcast) = broadcast; + let operation_id = broadcast.operation_id.expect("host operation id"); + + let first_stop = futures::executor::block_on(Chain::stop_transaction( + &host, + &cx, + RemoteChainTransactionStopRequest::V1(v01::RemoteChainTransactionStopRequest { + genesis_hash: genesis_hash.clone(), + operation_id: operation_id.clone(), + }), + )) + .unwrap_err(); + assert!( + matches!(first_stop, CallError::HostFailure { ref reason } if reason.contains("temporary stop failure")), + "unexpected error: {first_stop:?}", + ); + + futures::executor::block_on(Chain::stop_transaction( + &host, + &cx, + RemoteChainTransactionStopRequest::V1(v01::RemoteChainTransactionStopRequest { + genesis_hash, + operation_id, + }), + )) + .expect("operation should remain retryable"); + + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + let stop_operation_ids = sent + .iter() + .filter(|request| request.contains("transaction_v1_stop")) + .map(|request| serde_json::from_str::(request).unwrap()) + .map(|request| request["params"][0].clone()) + .collect::>(); + assert_eq!(stop_operation_ids, vec!["remote-op", "remote-op"]); + } + + #[test] + fn preimage_lookup_cache_hit_emits_once_and_stays_open() { + use crate::host_logic::bulletin::preimage_key; + use futures::FutureExt; + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let value = vec![4, 5, 6, 7]; + let key = preimage_key(&value); + host.services.cache_preimage(key, value.clone()); + let cx = CallContext::new(); let request = RemotePreimageLookupSubscribeRequest::V1(v01::RemotePreimageLookupSubscribeRequest { - key: vec![0; 32], + key: key.to_vec(), + }); + let mut subscription = futures::executor::block_on(host.lookup_subscribe(&cx, request)); + let item = futures::executor::block_on(subscription.next()).expect("preimage item"); + assert_eq!( + item, + RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { + value: Some(value) + }) + ); + // The subscription stays open (no completion/interrupt frame) after the + // single cache-hit emission. + assert!(subscription.next().now_or_never().is_none()); + } + + #[test] + fn preimage_lookup_forged_host_bytes_downgraded_to_miss() { + use crate::host_logic::bulletin::preimage_key; + + let value = vec![1, 1, 2, 3, 5, 8]; + let key = preimage_key(&value); + + // Host returns bytes that do not hash to the requested key. + let forged = Arc::new(StubPlatform { + preimage_lookup_value: Some(vec![9, 9, 9]), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(forged, test_spawner()); + let cx = CallContext::new(); + let request = + RemotePreimageLookupSubscribeRequest::V1(v01::RemotePreimageLookupSubscribeRequest { + key: key.to_vec(), + }); + let mut subscription = futures::executor::block_on(host.lookup_subscribe(&cx, request)); + let item = futures::executor::block_on(subscription.next()).expect("preimage item"); + assert_eq!( + item, + RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { + value: None + }) + ); + + // Correct bytes pass the integrity check through. + let genuine = Arc::new(StubPlatform { + preimage_lookup_value: Some(value.clone()), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(genuine, test_spawner()); + let request = + RemotePreimageLookupSubscribeRequest::V1(v01::RemotePreimageLookupSubscribeRequest { + key: key.to_vec(), }); let mut subscription = futures::executor::block_on(host.lookup_subscribe(&cx, request)); let item = futures::executor::block_on(subscription.next()).expect("preimage item"); assert_eq!( item, RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { - value: Some(vec![9, 8, 7]) + value: Some(value) }) ); } diff --git a/rust/crates/truapi-server/src/runtime/allowances.rs b/rust/crates/truapi-server/src/runtime/allowances.rs index aa5e2ad1..433f0919 100644 --- a/rust/crates/truapi-server/src/runtime/allowances.rs +++ b/rust/crates/truapi-server/src/runtime/allowances.rs @@ -87,6 +87,24 @@ pub(super) async fn write_allowance_key( .map_err(storage_error) } +pub(super) async fn remove_allowance_key( + storage: &(impl CoreStorage + ?Sized), + session: &SessionInfo, + product_id: &str, + resource: AllowanceResource, +) -> Result<(), AuthorityError> { + let mut entries = read_entries(storage, session).await?; + let before = entries.len(); + entries.retain(|entry| !(entry.product_id == product_id && entry.resource == resource)); + if entries.len() == before { + return Ok(()); + } + storage + .write_core_storage(storage_key(session)?, encode_entries(entries)) + .await + .map_err(storage_error) +} + pub(super) async fn clear_session_allowance_keys( storage: &(impl CoreStorage + ?Sized), session: &SessionInfo, diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 7b5ce02d..16b6959d 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -53,6 +53,17 @@ impl AuthoritySession { validation_id, } } + + pub(crate) fn primary_username(&self) -> Option<&str> { + self.full_username + .as_deref() + .filter(|value| !value.is_empty()) + .or_else(|| { + self.lite_username + .as_deref() + .filter(|value| !value.is_empty()) + }) + } } /// Typed account-authority failure before it is mapped to an API-specific error. @@ -70,6 +81,10 @@ pub(crate) enum AuthorityError { /// The authority cannot service the request. #[display("{reason}")] Unavailable { reason: String }, + /// The authority cannot service this request shape (e.g. an unsupported + /// transaction-extension version). + #[display("{reason}")] + NotSupported { reason: String }, /// Catch-all authority failure. #[display("{reason}")] Unknown { reason: String }, @@ -224,6 +239,12 @@ pub(crate) trait ProductAuthority: Send + Sync { /// Disconnect the current account-authority session. async fn disconnect(&self); + /// Refresh identity fields for the current session if the authority can do + /// so without user interaction. + async fn refresh_session_identity(&self) -> Option { + self.current_session() + } + /// Sign a SCALE transaction payload for a product account. async fn sign_payload( &self, @@ -282,6 +303,18 @@ pub(crate) trait ProductAuthority: Send + Sync { product_id: String, ) -> Result; + /// Evict any cached Bulletin allowance key for the product and allocate a + /// fresh one, increasing the existing allowance. + /// + /// Called after a submission is rejected for an exhausted/missing + /// allowance, where reusing the cached key would loop forever. + async fn refresh_bulletin_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result; + /// Sign exact statement-store proof bytes with a product-derived account. async fn sign_statement_store_product_payload( &self, diff --git a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs new file mode 100644 index 00000000..fed6d0e6 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -0,0 +1,630 @@ +//! In-core Bulletin preimage submission over the shared Subxt client. +//! +//! One submission at a time: build + sign the `TransactionStorage.store` +//! extrinsic against the current best block (Subxt resolves metadata, nonce, +//! and the mortality anchor), dry-run it via Subxt's transaction validation +//! (broadcast is spec-guaranteed silent on invalid transactions, so the +//! dry-run is the only deterministic error signal), then submit through +//! Subxt's transaction watch and classify the dispatch outcome from the +//! inclusion block's events. + +use std::sync::Mutex as StdMutex; +#[cfg(not(target_arch = "wasm32"))] +use std::time::{Duration, Instant}; +#[cfg(target_arch = "wasm32")] +use web_time::{Duration, Instant}; + +use futures::{FutureExt, pin_mut}; +use subxt::client::{Block, Blocks, OnlineClientAtBlockImpl}; +use subxt::config::substrate::SubstrateConfig; +use subxt::error::{DispatchError, ExtrinsicError, TransactionEventsError}; +use subxt::tx::{ + SubmittableTransaction, TransactionInBlock, TransactionInvalid, TransactionStatus, + TransactionUnknown, ValidationResult, +}; +#[cfg(not(target_arch = "wasm32"))] +use subxt_rpcs::RpcClient; +use tracing::{instrument, warn}; +use truapi::CallContext; +use truapi_platform::BulletinAllowanceKey; + +use crate::chain_runtime::ChainRuntime; +#[cfg(not(target_arch = "wasm32"))] +use crate::chain_runtime::RuntimeFailure; +use crate::host_logic::bulletin::{ + STORE_PALLET_NAME, allowance_signer, build_signed_store_transaction, preimage_key, +}; +use crate::host_logic::extrinsic::Sr25519Signer; + +/// Retry once when a broadcast cannot be verified after a successful dry-run. +/// This covers the post-allocation propagation window where dry-run can +/// succeed against one node while the authoring path rejects or drops the +/// broadcast. +const SUBMIT_ATTEMPTS: usize = 2; +/// Number of newer best blocks to try before treating a dry-run allowance +/// rejection as real. Wallet allocation can return before the freshly granted +/// Bulletin authorization is visible to the chain state used by dry-run. +const ALLOWANCE_DRY_RUN_PROPAGATION_BLOCKS: usize = 20; +/// Budget for the stream to produce the next best block used by a dry-run +/// retry. +const ALLOWANCE_DRY_RUN_BLOCK_TIMEOUT: Duration = Duration::from_secs(10); +/// Budget for the best-block stream to replay the current chain head. +const INITIALIZATION_TIMEOUT: Duration = Duration::from_secs(10); +/// Quiet window after which the newest replayed block is taken as the head. +/// The replay delivers the initialized finalized block first and any known +/// best block right after; active chains never need the full window. +const BEST_BLOCK_TIMEOUT: Duration = Duration::from_secs(2); + +/// `TransactionStorage` module errors that mean the allowance account itself +/// was rejected (missing or exhausted authorization), i.e. the one condition +/// where refreshing the allowance key and retrying can help. +const ALLOWANCE_REJECTED_MODULE_ERRORS: &[&str] = + &["AuthorizationNotFound", "PermanentAllowanceExceeded"]; + +/// Where a submission failed, for phase-tagged timeout reasons. +type Phase = &'static str; + +/// The at-block client every submission step runs against. +type BulletinAtBlock = OnlineClientAtBlockImpl; + +/// A signed store transaction bound to its build block, ready for the +/// dry-run and submission steps. +type SignedStore = SubmittableTransaction; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DryRunStatus { + Valid, + AllowanceRejected, +} + +/// Typed submission failure driving the retry decision at the runtime call +/// site. Wire mapping stays `v01::PreimageSubmitError::Unknown { reason }`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BulletinSubmitError { + /// Connection, block stream, metadata, or nonce plumbing failed. + ChainUnavailable { reason: String }, + /// The dry-run rejected the transaction for a non-allowance reason. + InvalidTransaction { kind: String }, + /// The allowance account was rejected; refresh + one retry may help. + AllowanceRejected { phase: AllowanceRejectionPhase }, + /// The dry-run saw a nonce race (`Future`/`Stale`); no refresh. + NonceRace, + /// The submission budget elapsed; `phase` names the step reached. + Timeout { phase: Phase }, + /// The transaction was broadcast but inclusion could not be verified. + BroadcastUnverified { reason: String }, + /// The extrinsic landed but its dispatch failed for a non-allowance + /// reason. + IncludedButFailed { pallet: String, error: String }, + /// The calling context was cancelled. + Cancelled, +} + +/// Which stage rejected the allowance account. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AllowanceRejectionPhase { + DryRun, + Dispatch, +} + +impl BulletinSubmitError { + fn is_retryable_submission_uncertain(&self, phase: Phase) -> bool { + matches!(self, Self::Timeout { phase } if *phase == "watch") + || (phase == "watch" && matches!(self, Self::BroadcastUnverified { .. })) + } + + /// Structured reason string carried in the wire error. + pub(crate) fn reason(&self) -> String { + match self { + Self::ChainUnavailable { reason } => { + format!("bulletin chain unavailable: {reason}") + } + Self::InvalidTransaction { kind } => format!("invalid: {kind}"), + Self::AllowanceRejected { phase } => { + let phase = match phase { + AllowanceRejectionPhase::DryRun => "dry-run", + AllowanceRejectionPhase::Dispatch => "dispatch", + }; + format!("allowance rejected: {phase}") + } + Self::NonceRace => "nonce race: retry".to_string(), + Self::Timeout { phase } => format!("timeout: {phase}, inclusion unverified"), + Self::BroadcastUnverified { reason } => format!("inclusion unverified: {reason}"), + Self::IncludedButFailed { pallet, error } => { + format!("dispatch error: {pallet}.{error}") + } + Self::Cancelled => "cancelled".to_string(), + } + } +} + +/// Bulletin-chain submission service shared by all product runtimes. +pub(crate) struct BulletinRpc { + chain: ChainRuntime, + genesis_hash: [u8; 32], + /// Serializes submissions: no same-account nonce races between + /// concurrent submits. + submit_lock: futures::lock::Mutex<()>, + /// Last phase entered, for timeout reasons. + phase: StdMutex, +} + +impl BulletinRpc { + /// Build a bulletin submission service over the shared chain runtime. + pub(crate) fn new(chain: ChainRuntime, genesis_hash: [u8; 32]) -> Self { + Self { + chain, + genesis_hash, + submit_lock: futures::lock::Mutex::new(()), + phase: StdMutex::new("connect"), + } + } + + /// Open a raw RPC client over the configured Bulletin chain. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn client(&self, label: &'static str) -> Result { + self.chain + .rpc_client(label, &self.genesis_hash) + .await + .map(RpcClient::new) + } + + /// Submit `value` as a Bulletin preimage signed by `allowance`, returning + /// the preimage key once the transaction is included and its dispatch + /// succeeded. + #[instrument(skip_all, fields(runtime.method = "bulletin_rpc.submit_preimage"))] + pub(crate) async fn submit_preimage( + &self, + cx: &CallContext, + budget: Duration, + allowance: &BulletinAllowanceKey, + value: &[u8], + ) -> Result, BulletinSubmitError> { + // Serialize submissions, keeping the lock wait cancellable. + let lock = self.submit_lock.lock().fuse(); + let lock_cancelled = cx.cancel().cancelled().fuse(); + pin_mut!(lock, lock_cancelled); + let _guard = futures::select! { + guard = lock => guard, + _ = lock_cancelled => return Err(BulletinSubmitError::Cancelled), + }; + + // The whole-submission budget is set by the Preimage::submit boundary + // and passed in explicitly; the context is used only for cancellation. + // The budget starts once the lock is held; dropping the flow on + // timeout/cancel drops its in-flight chain work. + let started = Instant::now(); + let mut attempt = 0; + loop { + attempt += 1; + let Some(remaining) = budget.checked_sub(started.elapsed()) else { + return Err(BulletinSubmitError::Timeout { + phase: self.current_phase(), + }); + }; + let flow = self.submit_flow(allowance, value).fuse(); + let timeout = futures_timer::Delay::new(remaining).fuse(); + let cancelled = cx.cancel().cancelled().fuse(); + pin_mut!(flow, timeout, cancelled); + let result = futures::select! { + result = flow => result, + () = timeout => Err(BulletinSubmitError::Timeout { phase: self.current_phase() }), + _ = cancelled => Err(BulletinSubmitError::Cancelled), + }; + match result { + Err(err) + if attempt < SUBMIT_ATTEMPTS + && err.is_retryable_submission_uncertain(self.current_phase()) => + { + warn!( + attempt, + reason = %err.reason(), + "Bulletin preimage broadcast not included; retrying" + ); + } + result => return result, + } + } + } + + async fn submit_flow( + &self, + allowance: &BulletinAllowanceKey, + value: &[u8], + ) -> Result, BulletinSubmitError> { + let key = preimage_key(value); + + self.enter_phase("connect"); + let client = self + .chain + .online_client(&self.genesis_hash) + .await + .map_err(|failure| BulletinSubmitError::ChainUnavailable { + reason: failure.reason(), + })?; + let mut best_blocks = client.stream_best_blocks().await.map_err(|error| { + BulletinSubmitError::ChainUnavailable { + reason: format!("best-block stream unavailable: {error}"), + } + })?; + let head = initial_best_block(&mut best_blocks).await?; + + self.enter_phase("build"); + let signer = allowance_signer(allowance) + .map_err(|reason| BulletinSubmitError::InvalidTransaction { kind: reason })?; + let signed = self + .build_signed_and_dry_run(&mut best_blocks, head, &signer, value) + .await?; + drop(best_blocks); + + self.enter_phase("watch"); + let in_block = watch_until_included(&signed).await?; + + self.enter_phase("events"); + require_dispatch_success(&in_block).await?; + + Ok(key.to_vec()) + } + + /// Build, sign, and dry-run the extrinsic against the chosen best block. + /// Broadcast never reports invalid transactions, so dry-run is the only + /// deterministic signal for stale allowances, nonce races, and encoding + /// errors. + async fn build_signed_and_dry_run( + &self, + best_blocks: &mut Blocks, + head: Block, + signer: &Sr25519Signer, + value: &[u8], + ) -> Result { + let mut block = head; + let mut allowance_rejections = 0; + loop { + self.enter_phase("build"); + let at_block = + block + .at() + .await + .map_err(|error| BulletinSubmitError::ChainUnavailable { + reason: format!("block {} unavailable: {error}", block.number()), + })?; + let signed = match build_signed_store_transaction(&at_block, signer, value).await { + Ok(signed) => signed, + Err(error) => { + warn!( + block = block.number(), + error = %error, + "Bulletin store transaction assembly failed" + ); + return Err(map_store_transaction_build_error(error)); + } + }; + + self.enter_phase("dry-run"); + let validity = match signed.validate().await { + Ok(validity) => validity, + Err(error) => { + warn!( + block = block.number(), + error = %error, + "Bulletin transaction dry-run failed" + ); + return Err(BulletinSubmitError::ChainUnavailable { + reason: format!("transaction dry-run unavailable: {error}"), + }); + } + }; + match Self::classify_dry_run_validity(validity)? { + DryRunStatus::Valid => return Ok(signed), + DryRunStatus::AllowanceRejected + if allowance_rejections < ALLOWANCE_DRY_RUN_PROPAGATION_BLOCKS => + { + allowance_rejections += 1; + warn!( + attempt = allowance_rejections, + "Bulletin allowance not visible to dry-run yet; rebuilding at next block" + ); + block = + next_best_block(best_blocks, ALLOWANCE_DRY_RUN_BLOCK_TIMEOUT, "dry-run") + .await?; + } + DryRunStatus::AllowanceRejected => { + return Err(BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::DryRun, + }); + } + } + } + } + + fn classify_dry_run_validity( + validity: ValidationResult, + ) -> Result { + match validity { + ValidationResult::Valid(_) => Ok(DryRunStatus::Valid), + ValidationResult::Invalid( + TransactionInvalid::Payment + | TransactionInvalid::Custom(_) + | TransactionInvalid::BadSigner, + ) => Ok(DryRunStatus::AllowanceRejected), + ValidationResult::Invalid(TransactionInvalid::Future | TransactionInvalid::Stale) => { + Err(BulletinSubmitError::NonceRace) + } + ValidationResult::Invalid(other) => Err(BulletinSubmitError::InvalidTransaction { + kind: format!("{other:?}"), + }), + ValidationResult::Unknown(TransactionUnknown::CannotLookup) => { + Err(BulletinSubmitError::ChainUnavailable { + reason: "transaction validity could not be looked up".to_string(), + }) + } + ValidationResult::Unknown(other) => Err(BulletinSubmitError::InvalidTransaction { + kind: format!("{other:?}"), + }), + } + } + + fn enter_phase(&self, phase: Phase) { + *self.phase.lock().expect("phase slot poisoned") = phase; + } + + fn current_phase(&self) -> Phase { + *self.phase.lock().expect("phase slot poisoned") + } +} + +/// Take the stream's replayed view of the current chain head: the initialized +/// finalized block arrives first, followed by the newest known best block. +/// Returns the newest block seen before a quiet [`BEST_BLOCK_TIMEOUT`] window; +/// falling back to the finalized block is safe for cold starts. +async fn initial_best_block( + blocks: &mut Blocks, +) -> Result, BulletinSubmitError> { + let mut block = next_best_block(blocks, INITIALIZATION_TIMEOUT, "connect").await?; + loop { + match next_best_block(blocks, BEST_BLOCK_TIMEOUT, "connect").await { + Ok(newer) => block = newer, + Err(BulletinSubmitError::Timeout { .. }) => return Ok(block), + Err(other) => return Err(other), + } + } +} + +async fn next_best_block( + blocks: &mut Blocks, + timeout: Duration, + phase: Phase, +) -> Result, BulletinSubmitError> { + let timeout = futures_timer::Delay::new(timeout).fuse(); + let next = blocks.next().fuse(); + pin_mut!(timeout, next); + futures::select! { + block = next => match block { + Some(Ok(block)) => Ok(block), + Some(Err(error)) => Err(BulletinSubmitError::ChainUnavailable { + reason: format!("Bulletin best-block stream failed: {error}"), + }), + None => Err(BulletinSubmitError::ChainUnavailable { + reason: "Bulletin best-block stream ended".to_string(), + }), + }, + () = timeout => Err(BulletinSubmitError::Timeout { phase }), + } +} + +fn map_store_transaction_build_error(error: ExtrinsicError) -> BulletinSubmitError { + match error { + ExtrinsicError::AccountNonceError { reason, .. } => BulletinSubmitError::ChainUnavailable { + reason: format!("account nonce unavailable: {reason}"), + }, + other => BulletinSubmitError::InvalidTransaction { + kind: format!("store transaction assembly failed: {other}"), + }, + } +} + +/// Submit the signed transaction and watch its progress until it lands in a +/// best or finalized block. +async fn watch_until_included( + signed: &SignedStore, +) -> Result, BulletinSubmitError> { + let unverified = |reason: String| BulletinSubmitError::BroadcastUnverified { reason }; + let mut progress = signed + .submit_and_watch() + .await + .map_err(|error| unverified(format!("transaction submit failed: {error}")))?; + while let Some(status) = progress.next().await { + let status = + status.map_err(|error| unverified(format!("transaction watch failed: {error}")))?; + match status { + TransactionStatus::InBestBlock(block) | TransactionStatus::InFinalizedBlock(block) => { + return Ok(block); + } + TransactionStatus::Invalid { message } => { + return Err(unverified(format!( + "transaction invalid after successful dry-run: {message}" + ))); + } + TransactionStatus::Dropped { message } => { + return Err(unverified(format!("transaction dropped: {message}"))); + } + TransactionStatus::Error { message } => { + return Err(unverified(format!("transaction error: {message}"))); + } + TransactionStatus::Validated + | TransactionStatus::Broadcasted + | TransactionStatus::NoLongerInBestBlock => {} + } + } + Err(unverified( + "transaction watch stream ended before inclusion".to_string(), + )) +} + +/// Require a successful dispatch outcome from the inclusion block's events. +/// Fail-closed: inclusion without an explicit `System.ExtrinsicSuccess` event +/// is reported as unverified, never as success. +async fn require_dispatch_success( + in_block: &TransactionInBlock, +) -> Result<(), BulletinSubmitError> { + let unverified = |reason: String| BulletinSubmitError::BroadcastUnverified { reason }; + match in_block.wait_for_success().await { + Ok(events) => { + for event in events.iter() { + let event = + event.map_err(|err| unverified(format!("invalid transaction event: {err}")))?; + if event.pallet_name() == "System" && event.event_name() == "ExtrinsicSuccess" { + return Ok(()); + } + } + Err(unverified( + "included, but the block reported no dispatch outcome".to_string(), + )) + } + Err(TransactionEventsError::ExtrinsicFailed(error)) => Err(classify_dispatch_error(error)), + Err(other) => Err(unverified(format!( + "transaction events unavailable: {other}" + ))), + } +} + +/// Map a dispatch failure to the submission error, singling out the +/// allowance-rejection module errors that a key refresh can fix. +fn classify_dispatch_error(error: DispatchError) -> BulletinSubmitError { + let DispatchError::Module(module_error) = &error else { + return BulletinSubmitError::IncludedButFailed { + pallet: "unknown".to_string(), + error: error.to_string(), + }; + }; + match module_error.details() { + Ok(details) => { + let pallet = details.pallet.name().to_string(); + let error = details.variant.name.clone(); + if pallet == STORE_PALLET_NAME + && ALLOWANCE_REJECTED_MODULE_ERRORS.contains(&error.as_str()) + { + BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::Dispatch, + } + } else { + BulletinSubmitError::IncludedButFailed { pallet, error } + } + } + Err(_) => BulletinSubmitError::IncludedButFailed { + pallet: "unknown".to_string(), + error: module_error.details_string(), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_logic::extrinsic::tests::bulletin_metadata; + use subxt::metadata::ArcMetadata; + + #[test] + fn dry_run_classifies_allowance_rejections_for_retry() { + for validity in [ + ValidationResult::Invalid(TransactionInvalid::Payment), + ValidationResult::Invalid(TransactionInvalid::Custom(7)), + ValidationResult::Invalid(TransactionInvalid::BadSigner), + ] { + assert_eq!( + BulletinRpc::classify_dry_run_validity(validity).unwrap(), + DryRunStatus::AllowanceRejected + ); + } + } + + #[test] + fn retries_only_uncertain_watch_phase_submissions() { + let unverified = BulletinSubmitError::BroadcastUnverified { + reason: "transaction invalid after successful dry-run".to_string(), + }; + assert!(unverified.is_retryable_submission_uncertain("watch")); + assert!(!unverified.is_retryable_submission_uncertain("events")); + + assert!( + BulletinSubmitError::Timeout { phase: "watch" } + .is_retryable_submission_uncertain("connect") + ); + assert!( + !BulletinSubmitError::Timeout { phase: "dry-run" } + .is_retryable_submission_uncertain("dry-run") + ); + } + + /// Decode a `DispatchError::Module` for the named error variant out of + /// the bulletin fixture metadata. + fn module_error(error_name: &str) -> DispatchError { + let metadata = ArcMetadata::from(bulletin_metadata()); + let pallet = metadata.pallet_by_name(STORE_PALLET_NAME).unwrap(); + let variant_index = (0..=u8::MAX) + .find(|index| { + pallet + .error_variant_by_index(*index) + .is_some_and(|variant| variant.name == error_name) + }) + .unwrap_or_else(|| panic!("fixture metadata lacks the {error_name} error")); + // `DispatchError::Module` is variant 3: (pallet index, 4 error bytes). + let bytes = [3, pallet.error_index(), variant_index, 0, 0, 0]; + DispatchError::decode_from(&bytes, metadata).unwrap() + } + + /// Any error variant that is not an allowance rejection. + fn non_allowance_error_name() -> String { + let metadata = ArcMetadata::from(bulletin_metadata()); + let pallet = metadata.pallet_by_name(STORE_PALLET_NAME).unwrap(); + (0..=u8::MAX) + .find_map(|index| { + let name = &pallet.error_variant_by_index(index)?.name; + (!ALLOWANCE_REJECTED_MODULE_ERRORS.contains(&name.as_str())).then(|| name.clone()) + }) + .expect("fixture metadata has a non-allowance error variant") + } + + #[test] + fn dispatch_errors_classify_allowance_rejections() { + assert_eq!( + classify_dispatch_error(module_error("AuthorizationNotFound")), + BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::Dispatch + } + ); + + let other = non_allowance_error_name(); + assert_eq!( + classify_dispatch_error(module_error(&other)), + BulletinSubmitError::IncludedButFailed { + pallet: STORE_PALLET_NAME.to_string(), + error: other + } + ); + } + + #[test] + fn error_reason_strings_are_stable() { + assert_eq!( + BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::DryRun + } + .reason(), + "allowance rejected: dry-run" + ); + assert_eq!(BulletinSubmitError::NonceRace.reason(), "nonce race: retry"); + assert_eq!( + BulletinSubmitError::Timeout { phase: "watch" }.reason(), + "timeout: watch, inclusion unverified" + ); + assert_eq!( + BulletinSubmitError::IncludedButFailed { + pallet: "TransactionStorage".to_string(), + error: "BadContext".to_string() + } + .reason(), + "dispatch error: TransactionStorage.BadContext" + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs index 0c36a897..8a9964b8 100644 --- a/rust/crates/truapi-server/src/runtime/identity.rs +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -1,24 +1,42 @@ //! People-chain identity lookup used to resolve usernames for a paired session. +use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(not(target_arch = "wasm32"))] use std::time::Duration; #[cfg(target_arch = "wasm32")] use web_time::Duration; use crate::chain_runtime::{ - ChainRuntime, wait_for_chain_head_best_hash, wait_for_chain_head_storage_value, + ChainHeadStorageValue, ChainHeadStorageValueLookup, ChainRuntime, + wait_for_chain_head_best_hash, wait_for_chain_head_storage_value, }; use crate::host_logic::identity::{ PeopleIdentity, decode_people_identity, resources_consumers_storage_key, }; use crate::host_logic::session::SessionInfo; +use futures::{FutureExt, pin_mut}; use tracing::{debug, instrument, warn}; -use truapi::latest::{ +use truapi::v01::{ OperationStartedResult, RemoteChainHeadFollowRequest, RemoteChainHeadStorageRequest, StorageQueryItem, StorageQueryType, }; +/// Budget for the whole People-chain lookup (best block + storage read). +const LOOKUP_TIMEOUT: Duration = Duration::from_secs(10); +const LOOKUP_RETRY_INTERVAL: Duration = Duration::from_secs(2); +const BEST_BLOCK_TIMEOUT: Duration = Duration::from_secs(2); + +/// Monotonic salt for local identity lookup follow ids, avoiding collisions +/// between concurrent People-chain identity lookups. +static IDENTITY_LOOKUP_COUNTER: AtomicU64 = AtomicU64::new(1); + +enum ConsumerRecordLookup { + Found(Vec), + Missing, + Inaccessible, +} + /// Fill in missing usernames by querying the people chain; returns the /// session unchanged when it already carries a username or no people chain /// is configured. @@ -33,7 +51,7 @@ pub(super) async fn resolve_session_identity_with_chain( } let preferred_account = session.identity_account_id.unwrap_or(session.public_key); - if !lookup_and_apply( + if lookup_and_apply( chain, people_chain_genesis_hash, preferred_account, @@ -41,6 +59,7 @@ pub(super) async fn resolve_session_identity_with_chain( "identity", ) .await + == LookupOutcome::NoRecord && preferred_account != session.public_key { let public_key = session.public_key; @@ -57,42 +76,60 @@ pub(super) async fn resolve_session_identity_with_chain( session } +/// Maximum lookup attempts per account on transient failure. The first attempt +/// warms the People-chain connection (cached per genesis), so a retry after a +/// cold-start timeout usually resolves immediately. +const IDENTITY_LOOKUP_MAX_ATTEMPTS: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LookupOutcome { + /// A username record was found and applied. + Applied, + /// The account has no consumer record (definitive; do not retry). + NoRecord, + /// The lookup failed transiently after exhausting retries. + Failed, +} + /// Look up `account`'s people-chain identity and apply any usernames to -/// `session`; returns whether a username record was found and applied. +/// `session`, retrying transient failures against the warmed connection. async fn lookup_and_apply( chain: &ChainRuntime, people_chain_genesis_hash: [u8; 32], account: [u8; 32], session: &mut SessionInfo, label: &str, -) -> bool { - match lookup_people_identity(chain, people_chain_genesis_hash, account).await { - Ok(Some(identity)) => { - debug!( - account = %hex::encode(account), - lite_username = identity.lite_username.as_deref().unwrap_or(""), - full_username = identity.full_username.as_deref().unwrap_or(""), - "People-chain {label} lookup found username" - ); - session.apply_usernames(identity.lite_username, identity.full_username); - true - } - Ok(None) => { - debug!( - account = %hex::encode(account), - "People-chain {label} lookup found no consumer record" - ); - false - } - Err(reason) => { - warn!( - account = %hex::encode(account), - %reason, - "People-chain {label} lookup failed" - ); - false +) -> LookupOutcome { + for attempt in 1..=IDENTITY_LOOKUP_MAX_ATTEMPTS { + match lookup_people_identity(chain, people_chain_genesis_hash, account).await { + Ok(Some(identity)) => { + debug!( + account = %hex::encode(account), + lite_username = identity.lite_username.as_deref().unwrap_or(""), + full_username = identity.full_username.as_deref().unwrap_or(""), + "People-chain {label} lookup found username" + ); + session.apply_usernames(identity.lite_username, identity.full_username); + return LookupOutcome::Applied; + } + Ok(None) => { + debug!( + account = %hex::encode(account), + "People-chain {label} lookup found no consumer record" + ); + return LookupOutcome::NoRecord; + } + Err(reason) => { + warn!( + account = %hex::encode(account), + attempt, + %reason, + "People-chain {label} lookup failed" + ); + } } } + LookupOutcome::Failed } #[instrument(skip_all, fields(runtime.method = "session.identity.lookup"))] @@ -101,18 +138,44 @@ async fn lookup_people_identity( people_chain_genesis_hash: [u8; 32], account_id: [u8; 32], ) -> Result, String> { - let genesis_hash = people_chain_genesis_hash.to_vec(); - let key = resources_consumers_storage_key(&account_id); - let lookup_id = { - use std::sync::atomic::{AtomicU64, Ordering}; + let timeout = futures_timer::Delay::new(LOOKUP_TIMEOUT).fuse(); + pin_mut!(timeout); + loop { + let lookup = fetch_consumer_record(chain, people_chain_genesis_hash, account_id).fuse(); + pin_mut!(lookup); + let lookup = futures::select! { + value = lookup => value?, + () = timeout => return Err("People-chain identity lookup timed out".to_string()), + }; + match lookup { + ConsumerRecordLookup::Found(value) => { + return decode_people_identity(&value).map(Some); + } + ConsumerRecordLookup::Missing => return Ok(None), + ConsumerRecordLookup::Inaccessible => {} + } - /// Monotonic salt for local identity lookup follow ids, avoiding - /// collisions between concurrent People-chain identity lookups. - static IDENTITY_LOOKUP_COUNTER: AtomicU64 = AtomicU64::new(1); + let retry = futures_timer::Delay::new(LOOKUP_RETRY_INTERVAL).fuse(); + pin_mut!(retry); + futures::select! { + () = retry => {}, + () = timeout => return Err("People-chain identity lookup timed out".to_string()), + } + } +} - IDENTITY_LOOKUP_COUNTER.fetch_add(1, Ordering::Relaxed) - }; - let follow_id = format!("truapi:identity:{}:{}", lookup_id, hex::encode(account_id),); +/// Read the raw `Resources.Consumers` record for `account_id` at a fresh +/// People-chain head. The key is built locally, so the read never needs the +/// People-chain metadata. +async fn fetch_consumer_record( + chain: &ChainRuntime, + people_chain_genesis_hash: [u8; 32], + account_id: [u8; 32], +) -> Result { + let genesis_hash = people_chain_genesis_hash.to_vec(); + let key = resources_consumers_storage_key(&account_id); + let lookup_id = IDENTITY_LOOKUP_COUNTER.fetch_add(1, Ordering::Relaxed); + let follow_id = format!("truapi:identity:{lookup_id}:{}", hex::encode(account_id)); let mut follow = chain.remote_chain_head_follow( follow_id.clone(), RemoteChainHeadFollowRequest { @@ -124,14 +187,14 @@ async fn lookup_people_identity( let hash = wait_for_chain_head_best_hash( &mut follow, "People-chain", - Duration::from_secs(10), - Duration::from_secs(2), + LOOKUP_TIMEOUT, + BEST_BLOCK_TIMEOUT, ) .await?; let response = chain .remote_chain_head_storage(RemoteChainHeadStorageRequest { - genesis_hash, - follow_subscription_id: follow_id, + genesis_hash: genesis_hash.clone(), + follow_subscription_id: follow_id.clone(), hash, items: vec![StorageQueryItem { key: key.clone(), @@ -141,23 +204,28 @@ async fn lookup_people_identity( }) .await .map_err(|failure| failure.reason())?; - let operation_id = match response.operation { OperationStartedResult::Started { operation_id } => operation_id, OperationStartedResult::LimitReached => { return Err("People-chain storage lookup limit reached".to_string()); } }; - let Some(value) = wait_for_chain_head_storage_value( + let value = wait_for_chain_head_storage_value( &mut follow, - &operation_id, - &key, - "People-chain", - Duration::from_secs(10), + ChainHeadStorageValueLookup { + chain, + genesis_hash: &genesis_hash, + follow_subscription_id: &follow_id, + operation_id: &operation_id, + key: &key, + label: "People-chain", + timeout: LOOKUP_TIMEOUT, + }, ) - .await? - else { - return Ok(None); - }; - decode_people_identity(&value).map(Some) + .await?; + Ok(match value { + ChainHeadStorageValue::Found(value) => ConsumerRecordLookup::Found(value), + ChainHeadStorageValue::Missing => ConsumerRecordLookup::Missing, + ChainHeadStorageValue::Inaccessible => ConsumerRecordLookup::Inaccessible, + }) } diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index 4969a42c..487e375d 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}; @@ -215,7 +215,7 @@ impl PairingHost { Ok(session) => { let resolved = resolve_session_identity_with_chain( &pairing_host.chain, - pairing_host.host_config.people_chain_genesis_hash, + pairing_host.host_config.identity_lookup_genesis_hash(), session, ) .await; @@ -461,6 +461,71 @@ impl PairingHost { require_current_session(&self.session_state, session) } + async fn refresh_current_session_identity(&self) -> Option { + let current = self.session_state.current()?; + let identity_genesis_hash = self.host_config.identity_lookup_genesis_hash(); + if current.has_username() || identity_genesis_hash == [0; 32] { + return Some(authority_session(¤t)); + } + + let resolved = resolve_session_identity_with_chain( + &self.chain, + identity_genesis_hash, + current.clone(), + ) + .await; + if !resolved.has_username() || resolved == current { + return self.current_session(); + } + + if !self + .session_state + .replace_session_if_current(¤t, resolved.clone()) + { + return self.current_session(); + } + self.auth_state + .connected(&connected_session_ui_info(&resolved)); + + if let Err(err) = self + .platform + .write_core_storage( + CoreStorageKey::AuthSession, + encode_persisted_session(&resolved), + ) + .await + { + warn!(reason = %err.reason, "refreshed session identity persist failed"); + } + + match self.session_state.current() { + Some(live) if live != resolved => { + if let Err(err) = self + .platform + .write_core_storage( + CoreStorageKey::AuthSession, + encode_persisted_session(&live), + ) + .await + { + warn!(reason = %err.reason, "live session identity persist repair failed"); + } + Some(authority_session(&live)) + } + None => { + if let Err(err) = self + .platform + .clear_core_storage(CoreStorageKey::AuthSession) + .await + { + warn!(reason = %err.reason, "cleared session identity persist repair failed"); + } + None + } + _ => Some(authority_session(&resolved)), + } + } + pub(super) async fn cache_statement_store_allowance_key( &self, session: &SessionInfo, @@ -589,6 +654,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,19 +782,27 @@ 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, + cx: &CallContext, session: &AuthoritySession, - _account: v01::ProductAccountId, - _payload: Vec, + account: v01::ProductAccountId, + payload: Vec, ) -> Result<[u8; 64], AuthorityError> { - self.current_private_session(session)?; - Err(AuthorityError::Unavailable { - reason: "pairing host: exact statement proof signing is not supported over the \ - current SSO raw-signing protocol" - .to_string(), - }) + let session = self.current_private_session(session)?; + self.remote_sign_statement_store_product_payload(cx, &session, account, payload) + .await } fn derive_entropy( @@ -769,6 +862,10 @@ impl ProductAuthority for PairingHost { PairingHost::disconnect(self).await; } + async fn refresh_session_identity(&self) -> Option { + self.refresh_current_session_identity().await + } + async fn sign_payload( &self, cx: &CallContext, @@ -835,6 +932,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..d0dc64c8 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -17,7 +17,8 @@ use crate::host_logic::sso::messages::{ OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, SsoAllocatedResource, SsoAllocationOutcome, SsoRemoteResponse, SsoSessionStatement, alias_request_message, build_outgoing_request_statement, create_transaction_message, decode_sso_session_statement, - resource_allocation_message, sign_payload_message, sign_raw_message, v1, + resource_allocation_message, sign_payload_message, sign_raw_message, + statement_store_product_sign_message, v1, }; use crate::host_logic::statement_store::parse_new_statements_result; @@ -37,6 +38,8 @@ enum RemoteAction { AccountAlias, #[display("resource-allocation")] ResourceAllocation, + #[display("statement-store-product-sign")] + StatementStoreProductSign, } /// Active peer-disconnect watcher for one SSO session; aborts on drop. @@ -377,6 +380,41 @@ impl PairingHost { response.payload.map_err(remote_authority_error) } + pub(super) async fn remote_sign_statement_store_product_payload( + &self, + cx: &CallContext, + session: &SessionInfo, + account: latest::ProductAccountId, + payload: Vec, + ) -> Result<[u8; 64], AuthorityError> { + let message_id = sso_message_id(); + let message = statement_store_product_sign_message(message_id, account, payload); + let response = self + .submit_remote_message( + cx, + session, + RemoteAction::StatementStoreProductSign, + message, + ) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::StatementStoreProductSign(response) = response else { + return Err(AuthorityError::Unknown { + reason: "Unexpected SSO response for statement-store proof signing request" + .to_string(), + }); + }; + let signature = response.signature.map_err(remote_authority_error)?; + signature + .try_into() + .map_err(|signature: Vec| AuthorityError::Unknown { + reason: format!( + "Invalid statement-store proof signature length: {}", + signature.len() + ), + }) + } + pub(super) async fn remote_allocate_resources( &self, cx: &CallContext, @@ -516,6 +554,58 @@ impl PairingHost { } } + pub(super) async fn remote_refresh_bulletin_allowance_key( + &self, + cx: &CallContext, + session: &SessionInfo, + product_id: String, + ) -> Result { + // Drop the cached (and persisted) key so a stale/exhausted slot is not + // reused, then request a fresh allocation with `Increase` so the + // wallet grants a new allowance rather than echoing the old slot. + self.evict_bulletin_allowance_key(session, &product_id) + .await?; + + let message_id = sso_message_id(); + let message = resource_allocation_message( + message_id, + product_id.clone(), + vec![latest::AllocatableResource::BulletinAllowance], + OnExistingAllowancePolicy::Increase, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::ResourceAllocation, message) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::ResourceAllocation(response) = response else { + return Err(AuthorityError::Unknown { + reason: "Unexpected SSO response for bulletin allowance refresh".to_string(), + }); + }; + let mut outcomes = response + .payload + .map_err(remote_authority_error)? + .into_iter(); + let outcome = outcomes.next().ok_or_else(|| AuthorityError::Unknown { + reason: "Empty bulletin allowance refresh response".to_string(), + })?; + match outcome { + SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { + slot_account_key, + }) => { + self.cache_bulletin_allowance_key(session, &product_id, slot_account_key) + .await + } + SsoAllocationOutcome::Allocated(other) => Err(AuthorityError::Unknown { + reason: format!("Unexpected bulletin allowance refresh resource: {other:?}"), + }), + SsoAllocationOutcome::Rejected => Err(AuthorityError::Rejected), + SsoAllocationOutcome::NotAvailable => Err(AuthorityError::Unavailable { + reason: "bulletin allowance is not available".to_string(), + }), + } + } + async fn cache_allowance_outcomes( &self, session: &SessionInfo, diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 97511af5..35c0292b 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -4,30 +4,43 @@ //! and signing hosts. Pairing state, signing state, active sessions, and role //! controls live on the concrete role objects. -use std::sync::Arc; +use std::collections::VecDeque; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; use crate::chain_runtime::{ChainRuntime, RuntimeChainProvider, RuntimeFailure}; +use crate::runtime::bulletin_rpc::BulletinRpc; use crate::runtime::statement_store_rpc::StatementStoreRpc; use crate::subscription::Spawner; use async_trait::async_trait; use truapi_platform::{JsonRpcConnection, Platform}; +/// Upper bound on the in-core preimage cache. The cache is a bridge until +/// content propagates to the lookup backend, not a store, so it stays small. +const PREIMAGE_CACHE_MAX_BYTES: usize = 16 * 1024 * 1024; + /// Infrastructure shared by all product runtimes created from one host role. pub(crate) struct RuntimeServices { pub(crate) platform: Arc, pub(crate) chain: ChainRuntime, pub(crate) statement_store: StatementStoreRpc, + /// In-core Bulletin submission over the configured Bulletin chain. + pub(crate) bulletin: BulletinRpc, + /// Values from confirmed in-core submissions, served to `lookup_subscribe` + /// until the host's content backend has them. Byte-bounded, oldest-first. + preimage_cache: Mutex, pub(crate) spawner: Spawner, next_core_instance: AtomicU64, } impl RuntimeServices { - /// Build role-neutral runtime services from the platform and People-chain - /// genesis hash used by statement-store backed protocols. + /// Build role-neutral runtime services from the platform, the People-chain + /// genesis hash used by statement-store backed protocols, and the + /// Bulletin-chain genesis hash used for in-core preimage submission. pub(crate) fn new( platform: Arc, people_chain_genesis_hash: [u8; 32], + bulletin_chain_genesis_hash: [u8; 32], spawner: Spawner, ) -> Arc { let chain_provider = Arc::new(HostChainProvider { @@ -36,10 +49,13 @@ impl RuntimeServices { let chain = ChainRuntime::new(chain_provider, spawner.clone()); let statement_store = StatementStoreRpc::new(platform.clone(), people_chain_genesis_hash, spawner.clone()); + let bulletin = BulletinRpc::new(chain.clone(), bulletin_chain_genesis_hash); Arc::new(Self { platform, chain, statement_store, + bulletin, + preimage_cache: Mutex::new(PreimageCache::default()), spawner, next_core_instance: AtomicU64::new(1), }) @@ -48,6 +64,60 @@ impl RuntimeServices { pub(crate) fn next_core_instance(&self) -> u64 { self.next_core_instance.fetch_add(1, Ordering::Relaxed) } + + /// Store a preimage value under its key for later lookup hits. + pub(crate) fn cache_preimage(&self, key: [u8; 32], value: Vec) { + self.preimage_cache + .lock() + .expect("preimage cache mutex poisoned") + .insert(key, value); + } + + /// Return a cached preimage value for `key`, if present. + pub(crate) fn cached_preimage(&self, key: &[u8; 32]) -> Option> { + self.preimage_cache + .lock() + .expect("preimage cache mutex poisoned") + .get(key) + } +} + +/// Byte-bounded, insertion-ordered preimage cache. +#[derive(Default)] +struct PreimageCache { + entries: VecDeque<([u8; 32], Vec)>, + total_bytes: usize, +} + +impl PreimageCache { + fn insert(&mut self, key: [u8; 32], value: Vec) { + if value.len() > PREIMAGE_CACHE_MAX_BYTES { + return; + } + if let Some(index) = self + .entries + .iter() + .position(|(existing, _)| *existing == key) + { + let (_, old) = self.entries.remove(index).expect("index in range"); + self.total_bytes -= old.len(); + } + self.total_bytes += value.len(); + self.entries.push_back((key, value)); + while self.total_bytes > PREIMAGE_CACHE_MAX_BYTES { + let Some((_, evicted)) = self.entries.pop_front() else { + break; + }; + self.total_bytes -= evicted.len(); + } + } + + fn get(&self, key: &[u8; 32]) -> Option> { + self.entries + .iter() + .find(|(existing, _)| existing == key) + .map(|(_, value)| value.clone()) + } } /// Adapter from `truapi_platform::ChainProvider` into the @@ -72,6 +142,8 @@ impl RuntimeChainProvider for HostChainProvider { .connect(genesis_hash) .await .map(Arc::from) - .map_err(|_| RuntimeFailure::unavailable("remote_chain_connect")) + .map_err(|err| { + RuntimeFailure::unavailable_with_reason("remote_chain_connect", format!("{err:?}")) + }) } } diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 4ec98c67..e0924467 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -5,12 +5,22 @@ //! embedding host at unlock through [`LocalActivation::activate_local_session`] //! (the host owns its persistence, e.g. the OS keychain) and kept in memory //! for the session, zeroized on disconnect. +//! +//! Implemented: local session lifecycle, raw-bytes signing, extrinsic-payload +//! signing, v4 transaction construction (payload fields and extensions arrive +//! pre-encoded, so no chain metadata is needed), RFC-0007 product entropy, and +//! bandersnatch ring-VRF product-account aliases (native only), and +//! product-scoped Bulletin allowance keys. Deferred (returns +//! [`AuthorityError::Unavailable`]): on-chain resource allocation. mod local_activation; +mod sso_responder; use std::sync::{Arc, Mutex}; pub(crate) use local_activation::LocalActivation; +pub use sso_responder::ResponderExit; +pub(crate) use sso_responder::respond_to_pairing; use super::authority::{ AuthorityError, AuthoritySession, BulletinAllowanceKey, CreateTransactionAuthorityRequest, @@ -19,12 +29,15 @@ 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, + ProductAccountError, SR25519_SIGNING_CONTEXT, derive_product_keypair, derive_sr25519_hard_path, }; use crate::host_logic::session::SessionState; +use crate::host_logic::sso::messages::OnExistingAllowancePolicy; +use crate::host_logic::transaction::extrinsic_payload_preimage; use crate::runtime::auth_state::AuthStateMachine; +use crate::runtime::services::RuntimeServices; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, v01}; @@ -36,6 +49,8 @@ const BYTES_WRAP_SUFFIX: &[u8] = b""; /// Wallet-local account authority for a signing host. pub(crate) struct SigningHost { + #[cfg(not(target_arch = "wasm32"))] + services: Arc, session_state: Arc, auth_state: AuthStateMachine, /// Root BIP-39 entropy held only while a session is active. @@ -43,8 +58,13 @@ pub(crate) struct SigningHost { } impl SigningHost { - pub(crate) fn new(platform: Arc) -> Arc { + pub(crate) fn new(platform: Arc, services: Arc) -> Arc { + #[cfg(target_arch = "wasm32")] + let _ = services; + Arc::new(Self { + #[cfg(not(target_arch = "wasm32"))] + services, session_state: SessionState::new(), auth_state: AuthStateMachine::new(platform), root_entropy: Mutex::new(None), @@ -65,26 +85,73 @@ impl SigningHost { .ok_or(AuthorityError::Disconnected) } - /// Derive the product-account keypair for `account` from the root entropy. + /// Derive the product-account keypair for `account` from the wallet root. /// - /// The root keypair is recomputed per call (PBKDF2, 2048 rounds, via - /// `substrate-bip39`) rather than cached: the signing host holds only the - /// raw, zeroizable entropy, never an expanded secret key. + /// Per host-spec C.5, product keys derive from the user's main wallet + /// account at `//wallet` (whose public key is `rootUserAccountId`), not the + /// bare BIP-39 root. The wallet keypair is recomputed per call; the signing + /// host holds only the raw, zeroizable entropy. fn product_keypair( &self, account: &v01::ProductAccountId, ) -> Result { let entropy = self.root_entropy()?; - let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; + let wallet = wallet_root_keypair(&entropy)?; let product_id = normalize_product_identifier(&account.dot_ns_identifier).map_err(|err| { AuthorityError::Unavailable { reason: err.to_string(), } })?; - derive_product_keypair(&root, &product_id, account.derivation_index) + derive_product_keypair(&wallet, &product_id, account.derivation_index) .map_err(product_authority_error) } + + #[cfg(not(target_arch = "wasm32"))] + async fn local_statement_store_allowance_key( + &self, + product_id: &str, + ) -> Result { + let secret = + sso_responder::allocate_statement_store_allowance(&self.services, self, product_id) + .await + .map_err(allocation_error)?; + StatementStoreAllowanceKey::from_secret_bytes(secret) + } + + #[cfg(target_arch = "wasm32")] + async fn local_statement_store_allowance_key( + &self, + _product_id: &str, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: statement-store allowance allocation is native-only".to_string(), + }) + } + + #[cfg(not(target_arch = "wasm32"))] + async fn local_bulletin_allowance_key( + &self, + product_id: &str, + policy: OnExistingAllowancePolicy, + ) -> Result { + let secret = + sso_responder::allocate_bulletin_allowance(&self.services, self, product_id, policy) + .await + .map_err(allocation_error)?; + BulletinAllowanceKey::from_secret_bytes(secret).map_err(AuthorityError::from) + } + + #[cfg(target_arch = "wasm32")] + async fn local_bulletin_allowance_key( + &self, + _product_id: &str, + _policy: OnExistingAllowancePolicy, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: Bulletin allowance allocation is native-only".to_string(), + }) + } } #[async_trait::async_trait] @@ -128,13 +195,26 @@ impl ProductAuthority for SigningHost { async fn sign_payload( &self, _cx: &CallContext, - _session: &AuthoritySession, - _request: SignPayloadAuthorityRequest, + session: &AuthoritySession, + request: SignPayloadAuthorityRequest, ) -> Result { - Err(AuthorityError::Unavailable { - reason: "signing host: extrinsic-payload signing needs chain-metadata payload \ - assembly (not yet implemented)" - .to_string(), + let (account, payload) = match request { + SignPayloadAuthorityRequest::Product(request) => (request.account, request.payload), + SignPayloadAuthorityRequest::LegacyAccount { + product_account, + request, + } => (product_account, request.payload), + }; + require_current_session(&self.session_state, session)?; + let keypair = self.product_keypair(&account)?; + let message = extrinsic_payload_preimage(&payload); + let signature = keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) + .to_bytes(); + Ok(v01::HostSignPayloadResponse { + signature: signature.to_vec(), + signed_transaction: None, }) } @@ -144,15 +224,16 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: SignRawAuthorityRequest, ) -> Result { - let SignRawAuthorityRequest::Product(request) = request else { - return Err(AuthorityError::Unavailable { - reason: "signing host: legacy-account raw signing is not yet implemented" - .to_string(), - }); + let (account, payload) = match request { + SignRawAuthorityRequest::Product(request) => (request.account, request.payload), + SignRawAuthorityRequest::LegacyAccount { + product_account, + request, + } => (product_account, request.payload), }; require_current_session(&self.session_state, session)?; - let keypair = self.product_keypair(&request.account)?; - let message = raw_payload_bytes(request.payload)?; + let keypair = self.product_keypair(&account)?; + let message = raw_payload_bytes(payload)?; let signature = keypair .secret .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) @@ -166,63 +247,144 @@ 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( &self, _cx: &CallContext, - _session: &AuthoritySession, - _product_account_id: v01::ProductAccountId, + session: &AuthoritySession, + product_account_id: v01::ProductAccountId, _requesting_product_id: String, ) -> Result { - Err(AuthorityError::Unavailable { - reason: "signing host: ring-VRF alias derivation not yet implemented".to_string(), - }) + #[cfg(target_arch = "wasm32")] + { + let _ = (session, product_account_id); + Err(AuthorityError::Unavailable { + reason: "signing host: ring-VRF alias derivation is native-only".to_string(), + }) + } + #[cfg(not(target_arch = "wasm32"))] + { + require_current_session(&self.session_state, session)?; + let entropy = self.root_entropy()?; + let product_id = normalize_product_identifier(&product_account_id.dot_ns_identifier) + .map_err(|err| AuthorityError::Unavailable { + reason: err.to_string(), + })?; + let alias = crate::host_logic::alias::derive_product_alias( + &entropy, + &product_id, + product_account_id.derivation_index, + ) + .map_err(|reason| AuthorityError::Unknown { reason })?; + Ok(v01::HostAccountGetAliasResponse { + context: alias.context, + alias: alias.alias.to_vec(), + }) + } } async fn allocate_resources( &self, _cx: &CallContext, - _session: &AuthoritySession, - _product_id: String, - _request: v01::HostRequestResourceAllocationRequest, + session: &AuthoritySession, + product_id: String, + request: v01::HostRequestResourceAllocationRequest, ) -> Result { - Err(AuthorityError::Unavailable { - reason: "signing host: on-chain resource allocation not yet implemented".to_string(), - }) + require_current_session(&self.session_state, session)?; + let mut outcomes = Vec::with_capacity(request.resources.len()); + for resource in request.resources { + let outcome = match resource { + v01::AllocatableResource::StatementStoreAllowance => { + self.local_statement_store_allowance_key(&product_id) + .await?; + v01::AllocationOutcome::Allocated + } + v01::AllocatableResource::BulletinAllowance => { + self.local_bulletin_allowance_key( + &product_id, + OnExistingAllowancePolicy::Ignore, + ) + .await?; + v01::AllocationOutcome::Allocated + } + v01::AllocatableResource::SmartContractAllowance(_) + | v01::AllocatableResource::AutoSigning => v01::AllocationOutcome::NotAvailable, + }; + outcomes.push(outcome); + } + Ok(v01::HostRequestResourceAllocationResponse { outcomes }) } async fn statement_store_allowance_key( &self, _cx: &CallContext, session: &AuthoritySession, - _product_id: String, + product_id: String, ) -> Result { require_current_session(&self.session_state, session)?; - Err(AuthorityError::Unavailable { - reason: "signing host: statement-store allowance allocation not yet implemented" - .to_string(), - }) + self.local_statement_store_allowance_key(&product_id).await } async fn bulletin_allowance_key( &self, _cx: &CallContext, session: &AuthoritySession, - _product_id: String, + product_id: String, ) -> Result { require_current_session(&self.session_state, session)?; - Err(AuthorityError::Unavailable { - reason: "signing host: bulletin allowance allocation not yet implemented".to_string(), - }) + self.local_bulletin_allowance_key(&product_id, OnExistingAllowancePolicy::Ignore) + .await + } + + async fn refresh_bulletin_allowance_key( + &self, + _cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result { + require_current_session(&self.session_state, session)?; + self.local_bulletin_allowance_key(&product_id, OnExistingAllowancePolicy::Increase) + .await } async fn sign_statement_store_product_payload( @@ -262,6 +424,51 @@ fn product_authority_error(err: ProductAccountError) -> AuthorityError { } } +#[cfg(not(target_arch = "wasm32"))] +fn allocation_error(reason: String) -> AuthorityError { + AuthorityError::Unavailable { reason } +} + +/// The user's main wallet keypair at `//wallet` (host-spec C.0), the root of +/// product-account derivation and the `rootUserAccountId` shared with paired +/// hosts. +pub(crate) fn wallet_root_keypair(entropy: &[u8]) -> Result { + derive_sr25519_hard_path(entropy, &["wallet"]).map_err(product_authority_error) +} + +#[cfg(test)] +fn derive_bulletin_allowance_key( + entropy: &[u8], + product_id: &str, +) -> Result { + let allowance = derive_sr25519_hard_path(entropy, &["allowance", "bulletin", product_id]) + .map_err(product_authority_error)?; + BulletinAllowanceKey::from_secret_bytes(allowance.secret.to_bytes().to_vec()) + .map_err(AuthorityError::from) +} + +/// Assemble and sign a transaction locally from caller-supplied, pre-encoded +/// parts. Only Extrinsic V4 (`tx_ext_version == 0`) is supported; the caller's +/// extension bytes carry the whole chain binding, so no metadata is consulted. +fn build_local_transaction( + keypair: &schnorrkel::Keypair, + call_data: &[u8], + extensions: &[v01::TxPayloadExtension], + tx_ext_version: u8, +) -> Result { + if tx_ext_version != 0 { + return Err(AuthorityError::NotSupported { + reason: format!( + "signing host: unsupported tx_ext_version {tx_ext_version}; only V4 \ + (tx_ext_version = 0) is supported for local transaction construction" + ), + }); + } + let signer = Sr25519Signer::from_keypair(keypair); + let transaction = build_signed_extrinsic_v4(&signer, call_data, extensions); + Ok(v01::HostCreateTransactionResponse { transaction }) +} + /// Wrap raw sign-message bytes in the `` envelope unless /// already wrapped, matching the polkadot-app raw-signing convention. /// @@ -303,12 +510,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 super::{ + BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, derive_bulletin_allowance_key, + raw_payload_bytes, }; + use crate::host_logic::extrinsic::tests::split_v4; + use crate::host_logic::product_account::{derive_product_keypair, derive_sr25519_hard_path}; use crate::test_support::{StubPlatform, test_spawner}; use truapi::api::{Account, Entropy, Signing}; use truapi::versioned::account::{HostAccountGetError, HostAccountGetRequest}; @@ -334,14 +545,16 @@ mod tests { }, PlatformInfo::default(), [0; 32], + [0xbb; 32], ) .expect("signing host config is valid"); let services = RuntimeServices::new( platform.clone(), config.people_chain_genesis_hash, + config.bulletin_chain_genesis_hash, test_spawner(), ); - let signing_host = SigningHostRole::new(platform); + let signing_host = SigningHostRole::new(platform, services.clone()); (services, signing_host) } @@ -389,8 +602,8 @@ mod tests { futures::executor::block_on(runtime.sign_raw(&cx, request)).expect("sign_raw ok"); assert!(response.signed_transaction.is_none()); - let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); let signature = schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); assert!( @@ -421,6 +634,167 @@ mod tests { assert!(matches!(err, CallError::Domain(HostSignRawError::V1(_)))); } + fn product_account(index: u32) -> v01::ProductAccountId { + v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: index, + } + } + + fn tx_payload(tx_ext_version: u8) -> v01::ProductAccountTxPayload { + v01::ProductAccountTxPayload { + signer: product_account(0), + genesis_hash: [0xaa; 32], + call_data: vec![0x00, 0x00], + extensions: vec![v01::TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![1], + additional_signed: vec![2, 3], + }], + tx_ext_version, + } + } + + #[test] + fn create_transaction_product_builds_verifiable_v4() { + let (_services, activation) = signing_runtime(); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = activation.current_session().expect("active session"); + let cx = CallContext::new(); + + let response = futures::executor::block_on(activation.create_transaction( + &cx, + &session, + CreateTransactionAuthorityRequest::Product(tx_payload(0)), + )) + .expect("create_transaction ok"); + + let (account, signature, tail) = split_v4(&response.transaction); + assert_eq!(tail, vec![1, 0x00, 0x00], "body tail is extra ++ call_data"); + + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); + assert_eq!(account, keypair.public.to_bytes()); + + // Payload = call_data ++ extra ++ additional_signed (call first). + 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 wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); + + let request = CreateTransactionAuthorityRequest::LegacyAccount { + product_account: product_account(0), + 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(); @@ -532,8 +906,8 @@ mod tests { }); let HostSignRawResponse::V1(response) = futures::executor::block_on(runtime.sign_raw(&cx, request)).expect("sign_raw ok"); - let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); let signature = schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); assert!( @@ -619,7 +993,159 @@ mod tests { } #[test] - fn deferred_operations_return_unavailable() { + fn sign_payload_verifies_against_derived_product_key() { + use super::super::authority::SignPayloadAuthorityRequest; + use crate::host_logic::transaction::extrinsic_payload_preimage; + + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let cx = CallContext::new(); + let payload = v01::HostSignPayloadData { + block_hash: vec![0xB1; 32], + block_number: vec![0x01], + era: vec![0x00], + genesis_hash: vec![0x61; 32], + method: vec![0x4D, 0x00], + nonce: vec![0x00], + spec_version: vec![0x51], + tip: vec![0x00], + transaction_version: vec![0x56], + signed_extensions: vec![], + version: 4, + asset_id: None, + metadata_hash: None, + mode: None, + with_signed_transaction: None, + }; + let request = v01::HostSignPayloadRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: payload.clone(), + }; + + let response = futures::executor::block_on(authority.sign_payload( + &cx, + &session, + SignPayloadAuthorityRequest::Product(request), + )) + .expect("sign_payload ok"); + + assert!(response.signed_transaction.is_none()); + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); + let signature = + schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); + assert!( + keypair + .public + .verify_simple( + b"substrate", + &extrinsic_payload_preimage(&payload), + &signature + ) + .is_ok(), + "signature verifies over the payload preimage", + ); + } + + #[test] + fn create_transaction_builds_verifiable_v4_extrinsic() { + use super::super::authority::CreateTransactionAuthorityRequest; + use crate::host_logic::transaction::transaction_signing_preimage; + + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let cx = CallContext::new(); + let extensions = vec![v01::TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![0x04], + additional_signed: vec![], + }]; + let payload = v01::ProductAccountTxPayload { + signer: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + genesis_hash: [0x61; 32], + call_data: vec![0x00, 0x00], + extensions: extensions.clone(), + tx_ext_version: 0, + }; + + let response = futures::executor::block_on(authority.create_transaction( + &cx, + &session, + CreateTransactionAuthorityRequest::Product(payload), + )) + .expect("create_transaction ok"); + + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); + let transaction = response.transaction; + let mut body = transaction.as_slice(); + let body_len = + as parity_scale_codec::Decode>::decode(&mut body) + .expect("compact length prefix") + .0 as usize; + assert_eq!(body.len(), body_len); + assert_eq!(body[0], 0x84); + assert_eq!(body[1], 0x00); + assert_eq!(&body[2..34], &keypair.public.to_bytes()); + assert_eq!(body[34], 0x01); + let signature = schnorrkel::Signature::from_bytes(&body[35..99]).unwrap(); + assert_eq!(body[99], 0x04); + assert_eq!(&body[100..], &[0x00, 0x00]); + assert!( + keypair + .public + .verify_simple( + b"substrate", + &transaction_signing_preimage(&[0x00, 0x00], &extensions), + &signature + ) + .is_ok(), + "extrinsic signature verifies over call ++ extra ++ implicit", + ); + } + + #[test] + fn create_transaction_rejects_v5_extension_version() { + use super::super::authority::CreateTransactionAuthorityRequest; + + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let cx = CallContext::new(); + let payload = v01::ProductAccountTxPayload { + signer: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + genesis_hash: [0x61; 32], + call_data: vec![], + extensions: vec![], + tx_ext_version: 1, + }; + + let err = futures::executor::block_on(authority.create_transaction( + &cx, + &session, + CreateTransactionAuthorityRequest::Product(payload), + )) + .expect_err("v5 rejected"); + + assert!(matches!(err, AuthorityError::NotSupported { .. })); + } + + #[test] + fn account_alias_returns_ring_vrf_alias() { let (_services, authority) = signing_runtime(); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation"); @@ -630,13 +1156,27 @@ mod tests { &cx, &session, v01::ProductAccountId { - dot_ns_identifier: "other.dot".to_string(), + dot_ns_identifier: "truapi-playground.dot".to_string(), derivation_index: 0, }, - "myapp.dot".to_string(), + "truapi-playground.dot".to_string(), )) - .expect_err("alias deferred"); - assert!(matches!(alias, AuthorityError::Unavailable { .. })); + .expect("alias derives"); + + let expected = + crate::host_logic::alias::derive_product_alias(&ENTROPY, "truapi-playground.dot", 0) + .unwrap(); + assert_eq!(alias.context, expected.context); + assert_eq!(alias.alias, expected.alias.to_vec()); + } + + #[test] + fn empty_resource_allocation_returns_empty_response() { + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let cx = CallContext::new(); let alloc = futures::executor::block_on(authority.allocate_resources( &cx, @@ -644,7 +1184,20 @@ mod tests { "myapp.dot".to_string(), v01::HostRequestResourceAllocationRequest { resources: vec![] }, )) - .expect_err("allocation deferred"); - assert!(matches!(alloc, AuthorityError::Unavailable { .. })); + .expect("empty allocation"); + assert!(alloc.outcomes.is_empty()); + } + + #[test] + fn bulletin_allowance_key_uses_product_scoped_ios_path() { + let key = derive_bulletin_allowance_key(&ENTROPY, "truapi-playground.dot") + .expect("bulletin allowance key"); + let expected = derive_sr25519_hard_path( + &ENTROPY, + &["allowance", "bulletin", "truapi-playground.dot"], + ) + .unwrap(); + + assert_eq!(key.as_secret_bytes(), &expected.secret.to_bytes()); } } diff --git a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs index 9da344b0..cf7899a0 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs @@ -1,5 +1,4 @@ -use super::{SigningHost, product_authority_error}; -use crate::host_logic::product_account::derive_root_keypair_from_entropy; +use super::{SigningHost, wallet_root_keypair}; use crate::host_logic::session::SessionInfo; use crate::runtime::authority::AuthorityError; use crate::runtime::connected_session_ui_info; @@ -16,14 +15,31 @@ pub(crate) trait LocalActivation: Send + Sync { /// Activate a local session from raw BIP-39 entropy, deriving the root /// public key and marking the session connected. async fn activate_local_session(&self, secret: Vec) -> Result<(), AuthorityError>; + + /// Activate a local session and attach known identity metadata from the + /// host's signer/account store. + async fn activate_local_session_with_identity( + &self, + secret: Vec, + lite_username: Option, + ) -> Result<(), AuthorityError>; } #[async_trait::async_trait] impl LocalActivation for SigningHost { async fn activate_local_session(&self, secret: Vec) -> Result<(), AuthorityError> { + self.activate_local_session_with_identity(secret, None) + .await + } + + async fn activate_local_session_with_identity( + &self, + secret: Vec, + lite_username: Option, + ) -> Result<(), AuthorityError> { let secret = Zeroizing::new(secret); - let root = derive_root_keypair_from_entropy(&secret).map_err(product_authority_error)?; - let public_key = root.public.to_bytes(); + let wallet = wallet_root_keypair(&secret)?; + let public_key = wallet.public.to_bytes(); *self .root_entropy .lock() @@ -33,7 +49,7 @@ impl LocalActivation for SigningHost { sso: None, root_entropy_source: None, identity_account_id: None, - lite_username: None, + lite_username, full_username: None, }; self.session_state.set_session(session.clone()); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs new file mode 100644 index 00000000..8a70b6db --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -0,0 +1,658 @@ +//! Signing-host responder half of the host-spec §B pairing protocol. +//! +//! Answers a pairing host's handshake proposal (QR/deeplink) with an +//! encrypted `Success` statement, then serves the encrypted SSO session: +//! acks every inbound request statement, dispatches the batched +//! [`v1::RemoteMessage`] requests onto the local signing authority, and posts +//! the response statements the pairing host is waiting for. Runs until the +//! peer sends `Disconnected`, the local session ends, or the transport fails. +//! +//! Sensitive operations consult [`truapi_platform::UserConfirmation`], the +//! same seam browser hosts use for their confirmation modals; a headless host +//! implements it with its approval policy. + +use std::collections::HashSet; +use std::sync::Arc; + +use parity_scale_codec::Encode; +use tracing::{debug, info, instrument, warn}; +use truapi::latest::HostAccountGetAliasResponse; +use truapi::{CallContext, v01}; +use truapi_platform::{ + CreateTransactionReview, SignPayloadReview, SignRawReview, UserConfirmationReview, +}; + +use super::SigningHost; +use crate::host_logic::entropy::root_entropy_source; +#[cfg(not(target_arch = "wasm32"))] +use crate::host_logic::product_account::ProductAccountError; +use crate::host_logic::product_account::derive_sr25519_hard_path; +use crate::host_logic::session::SsoSessionInfo; +use crate::host_logic::sso::messages::{ + self, CreateTransactionPayload, IncomingSsoRequest, OnExistingAllowancePolicy, RemoteMessage, + RemoteMessageData, ResourceAllocationResponse, RingVrfAliasResponse, SignRawLegacyResponse, + SigningPayloadResponseData, SigningRequest, SigningResponse, SsoAllocatableResource, + SsoAllocatedResource, SsoAllocationOutcome, StatementStoreProductSignResponse, + build_outgoing_request_statement, build_signed_session_response_statement, + decode_incoming_sso_request, v1, +}; +use crate::host_logic::sso::pairing::{ + ResponderIdentity, VersionedHandshakeProposal, bootstrap_topic, decode_pairing_deeplink, + derive_p256_keypair_from_entropy, encrypt_v2_handshake_response, + establish_responder_session_info, v2, +}; +use crate::host_logic::statement_store::{build_signed_statement, parse_new_statements_result}; +use crate::runtime::authority::{ + CreateTransactionAuthorityRequest, ProductAuthority, SignPayloadAuthorityRequest, + SignRawAuthorityRequest, +}; +use crate::runtime::services::RuntimeServices; +use crate::runtime::sso_remote::fresh_statement_expiry; +use crate::runtime::statement_store_rpc; + +/// Domain label for the responder's persistent P-256 encryption key. +const SSO_ENCRYPTION_KEY_LABEL: &[u8] = b"sso-encryption"; +/// Domain label for the identity chat key shared in the handshake payload. +const CHAT_KEY_LABEL: &[u8] = b"chat-encryption"; + +/// Terminal outcome of one responder serve loop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResponderExit { + /// The pairing host announced `Disconnected`. + PeerDisconnected, + /// The statement subscription ended without a disconnect message. + SubscriptionEnded, +} + +/// Answer `deeplink` and serve the resulting SSO session until it ends. +#[instrument(skip_all, fields(runtime.method = "sso_responder.respond_to_pairing"))] +pub(crate) async fn respond_to_pairing( + services: Arc, + signing_host: Arc, + deeplink: &str, +) -> Result { + let VersionedHandshakeProposal::V2(proposal) = decode_pairing_deeplink(deeplink)?; + let entropy = signing_host + .root_entropy() + .map_err(|err| format!("signing host has no active local session: {err}"))?; + // `//wallet` is the user's main account (host-spec C.0): it backs + // product-account derivation and is `rootUserAccountId`. The + // statement/identity account is `//wallet//sso`. Usernames may be + // registered on either (mobile registers `//wallet`, the bot + // `//wallet//sso`); the paired host's lookup tries identity then root. + let wallet = derive_sr25519_hard_path(&entropy, &["wallet"]) + .map_err(|err| format!("//wallet derivation failed: {err}"))?; + let statement = derive_sr25519_hard_path(&entropy, &["wallet", "sso"]) + .map_err(|err| format!("//wallet//sso derivation failed: {err}"))?; + let (encryption_secret_key, encryption_public_key) = + derive_p256_keypair_from_entropy(&entropy, SSO_ENCRYPTION_KEY_LABEL) + .map_err(|err| format!("responder P-256 derivation failed: {err}"))?; + let (identity_chat_private_key, _) = derive_p256_keypair_from_entropy(&entropy, CHAT_KEY_LABEL) + .map_err(|err| format!("responder chat-key derivation failed: {err}"))?; + let identity = ResponderIdentity { + statement_secret: statement.secret.to_bytes(), + statement_public_key: statement.public.to_bytes(), + encryption_secret_key, + encryption_public_key, + }; + let session = establish_responder_session_info( + &identity, + proposal.device.statement_account_id, + proposal.device.encryption_public_key, + )?; + + let success = v2::EncryptedResponse::Success(Box::new(v2::Success { + identity_account_id: identity.statement_public_key, + root_account_id: wallet.public.to_bytes(), + identity_chat_private_key, + sso_enc_pub_key: identity.encryption_public_key, + device_enc_pub_key: identity.encryption_public_key, + root_entropy_source: root_entropy_source(&entropy), + })); + let handshake = encrypt_v2_handshake_response(proposal.device.encryption_public_key, &success)?; + let topic = bootstrap_topic( + proposal.device.statement_account_id, + proposal.device.encryption_public_key, + ); + let statement = build_signed_statement( + &session, + topic, + topic, + handshake.encode(), + fresh_statement_expiry(), + )?; + services + .statement_store + .submit(statement, "sso-responder handshake") + .await?; + info!("answered pairing handshake, serving SSO session"); + + serve_session(services, signing_host, session).await +} + +/// Serve inbound session statements until the session ends. +#[instrument(skip_all, fields(runtime.method = "sso_responder.serve_session"))] +async fn serve_session( + services: Arc, + signing_host: Arc, + session: SsoSessionInfo, +) -> Result { + let rpc_client = services + .statement_store + .client("sso-responder session") + .await?; + let mut subscription = + statement_store_rpc::subscribe_match_all(&rpc_client, &[session.session_id_peer]) + .await + .map_err(|err| format!("sso-responder subscribe failed: {err}"))?; + let mut served_request_ids = HashSet::new(); + + while let Some(item) = subscription.next().await { + let value = item.map_err(|err| format!("sso-responder subscription failed: {err}"))?; + let page = parse_new_statements_result("sso-responder".to_string(), &value) + .map_err(|err| err.to_string())?; + for statement in page.statements { + let incoming = match decode_incoming_sso_request(&session, &statement) { + Ok(Some(incoming)) => incoming, + Ok(None) => continue, + Err(reason) => { + debug!(%reason, "ignoring undecodable session statement"); + continue; + } + }; + if !served_request_ids.insert(incoming.request_id.clone()) { + continue; + } + if let Some(exit) = serve_request(&services, &signing_host, &session, incoming).await? { + return Ok(exit); + } + } + } + Ok(ResponderExit::SubscriptionEnded) +} + +/// Ack one inbound request statement and answer its batched messages. +async fn serve_request( + services: &Arc, + signing_host: &Arc, + session: &SsoSessionInfo, + incoming: IncomingSsoRequest, +) -> Result, String> { + let ack = build_signed_session_response_statement( + session, + incoming.request_id.clone(), + 0, + fresh_statement_expiry(), + )?; + services + .statement_store + .submit(ack, "sso-responder ack") + .await?; + + for message in incoming.messages { + let RemoteMessageData::V1(request) = message.data; + if matches!(request, v1::RemoteMessage::Disconnected) { + info!("pairing host disconnected the SSO session"); + return Ok(Some(ResponderExit::PeerDisconnected)); + } + let Some(response) = + answer_remote_message(services, signing_host, message.message_id, request).await + else { + continue; + }; + let statement_request_id = format!("resp:{}", response.message_id); + let statement = build_outgoing_request_statement( + session, + statement_request_id, + vec![response], + fresh_statement_expiry(), + )?; + services + .statement_store + .submit(statement, "sso-responder response") + .await?; + } + Ok(None) +} + +/// Answer one application-level request message; `None` for message kinds +/// that take no response (responses echoed by the peer, unknown variants). +async fn answer_remote_message( + services: &Arc, + signing_host: &Arc, + message_id: String, + request: v1::RemoteMessage, +) -> Option { + let response_id = format!("{message_id}:response"); + let data = match request { + v1::RemoteMessage::SignRequest(request) => v1::RemoteMessage::SignResponse( + sign_response(services, signing_host, &message_id, *request).await, + ), + v1::RemoteMessage::RingVrfAliasRequest(request) => { + let payload = account_alias_response(signing_host, request).await; + v1::RemoteMessage::RingVrfAliasResponse(RingVrfAliasResponse { + responding_to: message_id, + payload, + }) + } + v1::RemoteMessage::ResourceAllocationRequest(request) => { + let payload = resource_allocation_response(services, signing_host, request).await; + if let Err(reason) = &payload { + warn!(%reason, "resource allocation request failed"); + } + v1::RemoteMessage::ResourceAllocationResponse(ResourceAllocationResponse { + responding_to: message_id, + payload, + }) + } + v1::RemoteMessage::CreateTransactionRequest(request) => { + let CreateTransactionPayload::V1(payload) = request.payload; + let signed_transaction = create_transaction_response( + services, + signing_host, + CreateTransactionReview::Product(payload.clone()), + CreateTransactionAuthorityRequest::Product(payload), + ) + .await; + v1::RemoteMessage::CreateTransactionResponse(messages::CreateTransactionResponse { + responding_to: message_id, + signed_transaction, + }) + } + v1::RemoteMessage::CreateTransactionLegacyRequest(_) => { + v1::RemoteMessage::CreateTransactionResponse(messages::CreateTransactionResponse { + responding_to: message_id, + signed_transaction: Err( + "signing host: legacy-account transactions are not supported".to_string(), + ), + }) + } + v1::RemoteMessage::SignRawLegacyRequest(_) => { + v1::RemoteMessage::SignRawLegacyResponse(SignRawLegacyResponse { + responding_to: message_id, + signature: Err( + "signing host: legacy-account raw signing is not supported".to_string() + ), + }) + } + v1::RemoteMessage::StatementStoreProductSignRequest(request) => { + let signature = statement_store_product_sign_response(signing_host, request).await; + v1::RemoteMessage::StatementStoreProductSignResponse( + StatementStoreProductSignResponse { + responding_to: message_id, + signature, + }, + ) + } + v1::RemoteMessage::Disconnected + | v1::RemoteMessage::SignResponse(_) + | v1::RemoteMessage::RingVrfAliasResponse(_) + | v1::RemoteMessage::ResourceAllocationResponse(_) + | v1::RemoteMessage::CreateTransactionResponse(_) + | v1::RemoteMessage::SignRawLegacyResponse(_) + | v1::RemoteMessage::StatementStoreProductSignResponse(_) => return None, + }; + Some(RemoteMessage { + message_id: response_id, + data: RemoteMessageData::V1(data), + }) +} + +async fn resource_allocation_response( + services: &Arc, + signing_host: &Arc, + request: messages::ResourceAllocationRequest, +) -> Result, String> { + let mut outcomes = Vec::with_capacity(request.resources.len()); + for resource in request.resources { + let outcome = match resource { + SsoAllocatableResource::StatementStoreAllowance => { + let slot_account_key = allocate_statement_store_allowance( + services, + signing_host, + &request.calling_product_id, + ) + .await?; + SsoAllocationOutcome::Allocated(SsoAllocatedResource::StatementStoreAllowance { + slot_account_key, + }) + } + SsoAllocatableResource::BulletinAllowance => { + let slot_account_key = allocate_bulletin_allowance( + services, + signing_host, + &request.calling_product_id, + request.on_existing, + ) + .await?; + SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { + slot_account_key, + }) + } + SsoAllocatableResource::SmartContractAllowance(_) + | SsoAllocatableResource::AutoSigning => SsoAllocationOutcome::NotAvailable, + }; + outcomes.push(outcome); + } + Ok(outcomes) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) async fn allocate_statement_store_allowance( + services: &Arc, + signing_host: &SigningHost, + product_id: &str, +) -> Result, String> { + use crate::runtime::statement_allowance::{ + self, fetch_chain_state, fetch_metadata, find_including_ring, register_statement_account, + }; + + let entropy = signing_host.root_entropy().map_err(|err| err.reason())?; + let allowance = + derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id]) + .map_err(product_account_error)?; + let target = allowance.public.to_bytes(); + let bandersnatch = statement_allowance::bandersnatch_entropy(&entropy); + let rpc = statement_allowance::rpc::RpcClient::new( + services + .statement_store + .client("statement-store allowance") + .await?, + ); + let metadata = fetch_metadata(&rpc).await?; + let chain_state = fetch_chain_state(&rpc).await?; + let current = statement_allowance::ring::read_current_ring_index(&rpc).await?; + let ring = find_including_ring(&rpc, &metadata, bandersnatch, current) + .await? + .ok_or_else(|| { + "signing account is not a LitePeople ring member; cannot grant statement-store allowance" + .to_string() + })?; + let period = statement_allowance::slot::current_period(current_unix_secs()?); + let outcome = register_statement_account( + &rpc, + &metadata, + &chain_state, + bandersnatch, + &target, + period, + &ring, + ) + .await?; + match outcome { + statement_allowance::RegistrationOutcome::Registered { + block_hash, + seq, + ring_index, + } => { + info!( + %product_id, + %block_hash, + seq, + ring_index, + "registered statement-store allowance" + ); + } + statement_allowance::RegistrationOutcome::AlreadyAllocated { seq } => { + info!( + %product_id, + seq, + "statement-store allowance already allocated" + ); + } + } + Ok(allowance.secret.to_bytes().to_vec()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) async fn allocate_bulletin_allowance( + services: &Arc, + signing_host: &SigningHost, + product_id: &str, + policy: OnExistingAllowancePolicy, +) -> Result, String> { + use crate::runtime::statement_allowance::{ + self, claim_long_term_storage, fetch_bulletin_allowance, fetch_chain_state, fetch_metadata, + find_including_ring, wait_bulletin_authorization, + }; + + const AUTHORIZATION_WAIT: std::time::Duration = std::time::Duration::from_secs(60); + + let entropy = signing_host.root_entropy().map_err(|err| err.reason())?; + let allowance = derive_sr25519_hard_path(&entropy, &["allowance", "bulletin", product_id]) + .map_err(product_account_error)?; + let target = allowance.public.to_bytes(); + + let bulletin_rpc = statement_allowance::rpc::RpcClient::new( + services + .bulletin + .client("bulletin allowance") + .await + .map_err(|err| err.reason())?, + ); + let current_allowance = fetch_bulletin_allowance(&bulletin_rpc, &target).await?; + if matches!(policy, OnExistingAllowancePolicy::Ignore) + && current_allowance.is_some_and(|allowance| allowance.available()) + { + return Ok(allowance.secret.to_bytes().to_vec()); + } + + let people_rpc = statement_allowance::rpc::RpcClient::new( + services + .statement_store + .client("bulletin allowance claim") + .await?, + ); + let metadata = fetch_metadata(&people_rpc).await?; + let chain_state = fetch_chain_state(&people_rpc).await?; + let bandersnatch = statement_allowance::bandersnatch_entropy(&entropy); + let current = statement_allowance::ring::read_current_ring_index(&people_rpc).await?; + let ring = find_including_ring(&people_rpc, &metadata, bandersnatch, current) + .await? + .ok_or_else(|| { + "signing account is not a LitePeople ring member; cannot grant Bulletin allowance" + .to_string() + })?; + let period_duration = statement_allowance::slot::long_term_storage_period_duration(&metadata)?; + let period = statement_allowance::slot::current_long_term_storage_period( + current_unix_secs()?, + period_duration, + )?; + let outcome = claim_long_term_storage( + &people_rpc, + &metadata, + &chain_state, + bandersnatch, + &target, + period, + &ring, + ) + .await?; + let statement_allowance::LongTermStorageOutcome::Claimed { + block_hash, + counter, + ring_index, + } = outcome; + info!( + %product_id, + %block_hash, + counter, + ring_index, + "claimed Bulletin long-term storage allowance" + ); + + let authorization = wait_bulletin_authorization( + &bulletin_rpc, + &target, + current_allowance, + AUTHORIZATION_WAIT, + ) + .await?; + info!( + %product_id, + remained_size = authorization.remained_size, + remained_transactions = authorization.remained_transactions, + "Bulletin authorization visible" + ); + Ok(allowance.secret.to_bytes().to_vec()) +} + +#[cfg(target_arch = "wasm32")] +pub(super) async fn allocate_statement_store_allowance( + _services: &Arc, + _signing_host: &SigningHost, + _product_id: &str, +) -> Result, String> { + Err("signing host: statement-store allowance allocation is native-only".to_string()) +} + +#[cfg(target_arch = "wasm32")] +pub(super) async fn allocate_bulletin_allowance( + _services: &Arc, + _signing_host: &SigningHost, + _product_id: &str, + _policy: OnExistingAllowancePolicy, +) -> Result, String> { + Err("signing host: Bulletin allowance allocation is native-only".to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn current_unix_secs() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| "system clock before UNIX epoch".to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn product_account_error(err: ProductAccountError) -> String { + err.to_string() +} + +/// Confirm and serve a payload or raw signing request. +async fn sign_response( + services: &Arc, + signing_host: &Arc, + message_id: &str, + request: SigningRequest, +) -> SigningResponse { + let payload = serve_sign_request(services, signing_host, request).await; + if let Err(reason) = &payload { + warn!(%reason, "sign request failed"); + } + SigningResponse { + responding_to: message_id.to_string(), + payload, + } +} + +async fn serve_sign_request( + services: &Arc, + signing_host: &Arc, + request: SigningRequest, +) -> Result { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + let cx = CallContext::new(); + let response = match request { + SigningRequest::Payload(request) => { + let request: v01::HostSignPayloadRequest = (*request).into(); + confirm( + services, + UserConfirmationReview::SignPayload(SignPayloadReview::Product(request.clone())), + ) + .await?; + signing_host + .sign_payload(&cx, &session, SignPayloadAuthorityRequest::Product(request)) + .await + } + SigningRequest::Raw(request) => { + let request: v01::HostSignRawRequest = request.into(); + confirm( + services, + UserConfirmationReview::SignRaw(SignRawReview::Product(request.clone())), + ) + .await?; + signing_host + .sign_raw(&cx, &session, SignRawAuthorityRequest::Product(request)) + .await + } + } + .map_err(|err| err.reason())?; + Ok(SigningPayloadResponseData { + signature: response.signature, + signed_transaction: response.signed_transaction, + }) +} + +async fn statement_store_product_sign_response( + signing_host: &Arc, + request: messages::StatementStoreProductSignRequest, +) -> Result, String> { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + let cx = CallContext::new(); + signing_host + .sign_statement_store_product_payload( + &cx, + &session, + request.product_account_id, + request.payload, + ) + .await + .map(|signature| signature.to_vec()) + .map_err(|err| err.reason()) +} + +/// Confirm and serve a transaction-creation request. +async fn create_transaction_response( + services: &Arc, + signing_host: &Arc, + review: CreateTransactionReview, + request: CreateTransactionAuthorityRequest, +) -> Result, String> { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + confirm(services, UserConfirmationReview::CreateTransaction(review)).await?; + let cx = CallContext::new(); + signing_host + .create_transaction(&cx, &session, request) + .await + .map(|response| response.transaction) + .map_err(|err| err.reason()) +} + +async fn account_alias_response( + signing_host: &Arc, + request: messages::RingVrfAliasRequest, +) -> Result { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + let cx = CallContext::new(); + signing_host + .account_alias( + &cx, + &session, + request.product_account_id, + request.product_id, + ) + .await + .map_err(|err| err.reason()) +} + +/// Run the platform confirmation seam; rejection and failure both refuse the +/// operation with an opaque reason (host-spec B.7). +async fn confirm( + services: &Arc, + review: UserConfirmationReview, +) -> Result<(), String> { + match services.platform.confirm_user_action(review).await { + Ok(true) => Ok(()), + Ok(false) => Err("Rejected".to_string()), + Err(err) => Err(format!("confirmation failed: {}", err.reason)), + } +} diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index ab6da4b5..0b8239ce 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -228,7 +228,7 @@ impl<'a> SsoPairingFlow<'a> { }; let resolve_session = resolve_session_identity_with_chain( &self.host.chain, - self.host.host_config.people_chain_genesis_hash, + self.host.host_config.identity_lookup_genesis_hash(), session, ) .fuse(); diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs new file mode 100644 index 00000000..cb9a1e95 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -0,0 +1,465 @@ +//! On-chain statement-store allowance registration (`set_statement_store_account`). +//! +//! Mirrors how an iOS/web client obtains statement-store allowance from the real +//! People chain: build the `Resources.set_statement_store_account` call, prove +//! LitePeople ring membership with a bandersnatch ring-VRF, and submit the +//! resulting unsigned General (v5) extrinsic. Native only (needs the +//! `verifiable` prover and live chain reads). + +pub mod dynamic; +pub mod extension; +pub mod extrinsic; +pub mod proof; +pub mod ring; +pub mod rpc; +pub mod slot; + +use std::time::{Duration, Instant}; + +use blake2_rfc::blake2b::blake2b; +use futures::FutureExt; +use parity_scale_codec::Decode; +use serde_json::{Value, json}; +use sp_crypto_hashing::twox_128; +use tracing::{info, warn}; + +use extension::{ChainState, Metadata}; +use ring::RingParams; +use rpc::RpcClient; +use slot::SlotSelection; + +/// Bandersnatch entropy for a bip39 entropy: `blake2b256(bip39_entropy)`. +pub fn bandersnatch_entropy(bip39_entropy: &[u8]) -> [u8; 32] { + blake2b(32, &[], bip39_entropy) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +/// Fetch and decode the runtime metadata (`state_getMetadata`). +pub async fn fetch_metadata(rpc: &RpcClient) -> Result { + let value = rpc + .call("state_getMetadata", json!([])) + .await + .map_err(|e| e.to_string())?; + let hex_str = value + .as_str() + .ok_or_else(|| "state_getMetadata returned non-string".to_string())?; + let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(hex_str)) + .map_err(|e| format!("metadata hex: {e}"))?; + // `state_getMetadata` may return either the raw `RuntimeMetadataPrefixed` + // (starts with the `meta` magic) or an OpaqueMetadata wrapper + // (`Vec` = compact(len) ‖ bytes). Strip the wrapper only when present. + const META_MAGIC: [u8; 4] = *b"meta"; + if bytes.get(..4) == Some(&META_MAGIC) { + Metadata::decode(&bytes) + } else { + let inner = + Vec::::decode(&mut &bytes[..]).map_err(|e| format!("opaque metadata: {e}"))?; + Metadata::decode(&inner) + } +} + +/// Fetch the chain state needed to fill the signed extensions. +pub async fn fetch_chain_state(rpc: &RpcClient) -> Result { + let genesis_hex = rpc + .call("chain_getBlockHash", json!([0])) + .await + .map_err(|e| e.to_string())?; + let genesis_str = genesis_hex + .as_str() + .ok_or_else(|| "chain_getBlockHash returned non-string".to_string())?; + let genesis = hex::decode(genesis_str.strip_prefix("0x").unwrap_or(genesis_str)) + .map_err(|e| format!("genesis hex: {e}"))?; + let genesis_hash: [u8; 32] = genesis + .try_into() + .map_err(|_| "genesis hash is not 32 bytes".to_string())?; + + let runtime = rpc + .call("state_getRuntimeVersion", json!([])) + .await + .map_err(|e| e.to_string())?; + let spec_version = json_u32(&runtime, "specVersion")?; + let transaction_version = json_u32(&runtime, "transactionVersion")?; + + Ok(ChainState { + spec_version, + transaction_version, + genesis_hash, + nonce: 0, + }) +} + +/// Read a u32 field from a JSON object. +fn json_u32(value: &Value, field: &str) -> Result { + value + .get(field) + .and_then(Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .ok_or_else(|| format!("missing/invalid {field}")) +} + +/// Result of a statement-store allowance registration attempt. +pub enum RegistrationOutcome { + /// The extrinsic reached a block; the target now holds slot `seq`. + Registered { + /// Block hash the extrinsic landed in. + block_hash: String, + /// Claimed slot sequence. + seq: u32, + /// Ring index the proof was built against. + ring_index: u32, + }, + /// The target already held a slot this period; nothing submitted. + AlreadyAllocated { + /// Existing slot sequence. + seq: u32, + }, +} + +/// Result of a long-term storage claim attempt. +pub enum LongTermStorageOutcome { + /// The extrinsic reached a block; the target should receive Bulletin + /// authorization once XCM/chain propagation completes. + Claimed { + /// Block hash the extrinsic landed in. + block_hash: String, + /// Claimed counter within the long-term storage period. + counter: u8, + /// Ring index the proof was built against. + ring_index: u32, + }, +} + +/// Bulletin authorization state for one account. +#[derive(Debug, Clone, Copy)] +pub struct BulletinAllowanceInfo { + pub remained_size: u64, + pub remained_transactions: u32, + pub expires_in: u32, + pub fetched_at: u32, +} + +impl BulletinAllowanceInfo { + pub fn available(self) -> bool { + self.remained_size > 0 + && self.remained_transactions > 0 + && self.fetched_at < self.expires_in + } +} + +/// Find the newest ring (scanning up to `lookback` back from the current index) +/// that includes our member key. Reads the ring exponent once and stops at the +/// first match. +pub async fn find_including_ring( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + lookback: u32, +) -> Result, String> { + let member = proof::member_key(entropy); + let exponent = ring::read_ring_exponent(rpc, metadata).await?; + let current = ring::read_current_ring_index(rpc).await?; + let oldest = current.saturating_sub(lookback); + for ring_index in (oldest..=current).rev() { + let members = ring::read_ring_members_at(rpc, ring_index).await?; + if members.contains(&member) { + return Ok(Some(RingParams { + members, + exponent, + ring_index, + })); + } + } + Ok(None) +} + +/// Register statement-store allowance for `target`, proving membership in the +/// already-located `ring`, at UTC-day `period`. +pub async fn register_statement_account( + rpc: &RpcClient, + metadata: &Metadata, + chain_state: &ChainState, + entropy: [u8; 32], + target: &[u8; 32], + period: u32, + ring: &RingParams, +) -> Result { + let mut skipped_duplicate_slots = Vec::new(); + loop { + let seq = match slot::scan_slot_excluding( + rpc, + metadata, + entropy, + period, + target, + &skipped_duplicate_slots, + ) + .await? + { + SlotSelection::AlreadyAllocated(seq) => { + return Ok(RegistrationOutcome::AlreadyAllocated { seq }); + } + SlotSelection::Free(seq) => seq, + }; + + let context = slot::derive_slot_context(period, seq); + let call = extrinsic::build_set_statement_store_account_call(period, seq, target); + let message = extension::build_proof_message(metadata, &call, chain_state)?; + let domain = proof::domain_for_ring_exponent(ring.exponent)?; + let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; + let as_resources_extra = extrinsic::build_as_resources_extra(&ring_proof, ring.ring_index); + let extrinsic = + extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; + + match rpc.submit_and_watch(&extrinsic).await { + Ok(block_hash) => { + return Ok(RegistrationOutcome::Registered { + block_hash, + seq, + ring_index: ring.ring_index, + }); + } + Err(err) if duplicate_submit_error(&err) => { + skipped_duplicate_slots.push(seq); + } + Err(err) => return Err(err.to_string()), + } + } +} + +/// Claim long-term Bulletin storage authorization for `target`, proving +/// membership in the already-located `ring`, at People-chain `period`. +pub async fn claim_long_term_storage( + rpc: &RpcClient, + metadata: &Metadata, + chain_state: &ChainState, + entropy: [u8; 32], + target: &[u8; 32], + period: u32, + ring: &RingParams, +) -> Result { + let revision = ring::read_ring_revision(rpc, metadata, ring.ring_index).await?; + let mut skipped_duplicate_counters = Vec::new(); + loop { + let counter = slot::scan_long_term_storage_counter_excluding( + rpc, + metadata, + entropy, + period, + &skipped_duplicate_counters, + ) + .await?; + + let context = slot::derive_long_term_storage_context(period, counter); + let call = extrinsic::build_claim_long_term_storage_call(period, counter, target); + let message = extension::build_proof_message(metadata, &call, chain_state)?; + let domain = proof::domain_for_ring_exponent(ring.exponent)?; + let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; + let as_resources_extra = + extrinsic::build_long_term_storage_extra(&ring_proof, ring.ring_index, revision); + let extrinsic = + extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; + info!( + period, + counter, + ring_index = ring.ring_index, + revision, + "submitting Bulletin long-term-storage claim" + ); + + match rpc.submit_and_watch(&extrinsic).await { + Ok(block_hash) => { + return Ok(LongTermStorageOutcome::Claimed { + block_hash, + counter, + ring_index: ring.ring_index, + }); + } + Err(err) if duplicate_submit_error(&err) => { + skipped_duplicate_counters.push(counter); + } + Err(err) => { + warn!( + period, + counter, + ring_index = ring.ring_index, + revision, + %err, + "Bulletin long-term-storage claim failed" + ); + return Err(err.to_string()); + } + } + } +} + +/// Fetch Bulletin `TransactionStorage.Authorizations[Account(target)]`. +pub async fn fetch_bulletin_allowance( + rpc: &RpcClient, + target: &[u8; 32], +) -> Result, String> { + let Some(bytes) = rpc + .get_storage(&bulletin_authorization_key(target)) + .await + .map_err(|e| e.to_string())? + else { + return Ok(None); + }; + let fetched_at = fetch_block_number(rpc).await?; + decode_bulletin_allowance(&bytes, fetched_at).map(Some) +} + +/// Wait until Bulletin authorization is available and fresher than `current`. +pub async fn wait_bulletin_authorization( + rpc: &RpcClient, + target: &[u8; 32], + current: Option, + timeout: Duration, +) -> Result { + let started = Instant::now(); + let baseline = current.filter(|info| info.available()); + loop { + let Some(info) = fetch_bulletin_allowance(rpc, target).await? else { + wait_before_next_bulletin_authorization_poll(started, timeout).await?; + continue; + }; + if authorization_refreshed(info, baseline) { + return Ok(info); + } + wait_before_next_bulletin_authorization_poll(started, timeout).await?; + } +} + +async fn wait_before_next_bulletin_authorization_poll( + started: Instant, + timeout: Duration, +) -> Result<(), String> { + let Some(remaining) = timeout.checked_sub(started.elapsed()) else { + return Err("timed out waiting for Bulletin authorization".to_string()); + }; + let delay = futures_timer::Delay::new(remaining.min(Duration::from_secs(2))).fuse(); + futures::pin_mut!(delay); + delay.await; + Ok(()) +} + +fn authorization_refreshed( + info: BulletinAllowanceInfo, + baseline: Option, +) -> bool { + if !info.available() { + return false; + } + match baseline { + None => true, + Some(current) => { + info.remained_transactions > current.remained_transactions + || info.remained_size > current.remained_size + || info.expires_in > current.expires_in + } + } +} + +/// `TransactionStorage.Authorizations[AuthorizationScope::Account(target)]`. +fn bulletin_authorization_key(target: &[u8; 32]) -> Vec { + let mut scope = Vec::with_capacity(1 + 32); + scope.push(0x00); + scope.extend_from_slice(target); + [ + twox_128(b"TransactionStorage").as_slice(), + twox_128(b"Authorizations").as_slice(), + &ring::blake2_128_concat(&scope), + ] + .concat() +} + +fn decode_bulletin_allowance( + bytes: &[u8], + fetched_at: u32, +) -> Result { + let mut input = bytes; + let transactions = + u32::decode(&mut input).map_err(|err| format!("authorization transactions: {err}"))?; + let transactions_allowance = u32::decode(&mut input) + .map_err(|err| format!("authorization transactions_allowance: {err}"))?; + let bytes_used = + u64::decode(&mut input).map_err(|err| format!("authorization bytes: {err}"))?; + let _bytes_permanent = + u64::decode(&mut input).map_err(|err| format!("authorization bytes_permanent: {err}"))?; + let bytes_allowance = + u64::decode(&mut input).map_err(|err| format!("authorization bytes_allowance: {err}"))?; + let expires_in = + u32::decode(&mut input).map_err(|err| format!("authorization expiration: {err}"))?; + Ok(BulletinAllowanceInfo { + remained_size: bytes_allowance.saturating_sub(bytes_used), + remained_transactions: transactions_allowance.saturating_sub(transactions), + expires_in, + fetched_at, + }) +} + +async fn fetch_block_number(rpc: &RpcClient) -> Result { + let header = rpc + .call("chain_getHeader", json!([])) + .await + .map_err(|err| err.to_string())?; + let number = header + .get("number") + .and_then(Value::as_str) + .ok_or_else(|| "chain_getHeader returned no number".to_string())?; + u32::from_str_radix(number.trim_start_matches("0x"), 16) + .map_err(|err| format!("chain_getHeader number: {err}")) +} + +fn duplicate_submit_error(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("priority is too low") + || message.contains("already imported") + || message.contains("temporarily banned") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn allowance( + remained_size: u64, + remained_transactions: u32, + expires_in: u32, + ) -> BulletinAllowanceInfo { + BulletinAllowanceInfo { + remained_size, + remained_transactions, + expires_in, + fetched_at: 10, + } + } + + #[test] + fn bulletin_refresh_accepts_available_state_when_baseline_was_unusable() { + let exhausted_by_size = allowance(0, 4, 100); + let refreshed_same_transactions = allowance(4096, 4, 100); + + assert!(!exhausted_by_size.available()); + assert!(authorization_refreshed( + refreshed_same_transactions, + Some(exhausted_by_size).filter(|info| info.available()), + )); + } + + #[test] + fn bulletin_refresh_accepts_size_only_increase() { + let baseline = allowance(128, 4, 100); + let refreshed = allowance(4096, 4, 100); + + assert!(authorization_refreshed(refreshed, Some(baseline))); + } + + #[test] + fn bulletin_refresh_rejects_unchanged_available_state() { + let baseline = allowance(128, 4, 100); + + assert!(!authorization_refreshed(baseline, Some(baseline))); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs new file mode 100644 index 00000000..42e1df77 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs @@ -0,0 +1,164 @@ +//! Minimal metadata-driven SCALE walker. +//! +//! Just enough to read one field out of a storage struct without a full dynamic +//! codec: `skip` advances a cursor past one value of a given type, +//! `read_field_variant_name` walks a composite to a named field and returns its +//! enum variant name (used for `CollectionInfo.ring_size` -> `R2e9`/`R2e10`/`R2e14`), +//! and `read_field_u32` reads simple numeric fields such as `RingRoot.revision`. + +use parity_scale_codec::{Compact, Decode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + +/// Advance `input` past exactly one SCALE-encoded value of `type_id`. +pub fn skip(registry: &PortableRegistry, type_id: u32, input: &mut &[u8]) -> Result<(), String> { + let ty = registry + .resolve(type_id) + .ok_or_else(|| format!("unknown type id {type_id}"))?; + match &ty.type_def { + TypeDef::Composite(c) => { + for field in &c.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Tuple(t) => { + for field in &t.fields { + skip(registry, field.id, input)?; + } + } + TypeDef::Array(a) => { + for _ in 0..a.len { + skip(registry, a.type_param.id, input)?; + } + } + TypeDef::Sequence(s) => { + let len = read_compact(input)?; + for _ in 0..len { + skip(registry, s.type_param.id, input)?; + } + } + TypeDef::Variant(v) => { + let index = read_u8(input)?; + let variant = v + .variants + .iter() + .find(|var| var.index == index) + .ok_or_else(|| format!("unknown variant index {index}"))?; + for field in &variant.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Compact(_) => { + read_compact(input)?; + } + TypeDef::BitSequence(_) => { + let bits = read_compact(input)?; + advance(input, bits.div_ceil(8))?; + } + TypeDef::Primitive(p) => { + let len = match p { + TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => 1, + TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => 2, + TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => 4, + TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => 8, + TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => 16, + TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => 32, + // Length-prefixed UTF-8: compact byte length then the bytes. + TypeDefPrimitive::Str => read_compact(input)?, + }; + advance(input, len)?; + } + } + Ok(()) +} + +/// Walk composite `struct_type_id` to `field_name` and return the enum variant +/// name selected there (the field must be a fieldless/simple enum). +pub fn read_field_variant_name( + registry: &PortableRegistry, + struct_type_id: u32, + field_name: &str, + bytes: &[u8], +) -> Result { + let ty = registry + .resolve(struct_type_id) + .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; + let TypeDef::Composite(composite) = &ty.type_def else { + return Err(format!("type {struct_type_id} is not a composite")); + }; + + let mut input = bytes; + for field in &composite.fields { + if field.name.as_deref() == Some(field_name) { + let field_ty = registry + .resolve(field.ty.id) + .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; + let TypeDef::Variant(variant) = &field_ty.type_def else { + return Err(format!("field `{field_name}` is not an enum")); + }; + let index = read_u8(&mut input)?; + return variant + .variants + .iter() + .find(|var| var.index == index) + .map(|var| var.name.clone()) + .ok_or_else(|| format!("unknown variant index {index} for `{field_name}`")); + } + skip(registry, field.ty.id, &mut input)?; + } + Err(format!("field `{field_name}` not found")) +} + +/// Walk composite `struct_type_id` to `field_name` and decode the field as a +/// SCALE `u32`. +pub fn read_field_u32( + registry: &PortableRegistry, + struct_type_id: u32, + field_name: &str, + bytes: &[u8], +) -> Result { + let ty = registry + .resolve(struct_type_id) + .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; + let TypeDef::Composite(composite) = &ty.type_def else { + return Err(format!("type {struct_type_id} is not a composite")); + }; + + let mut input = bytes; + for field in &composite.fields { + if field.name.as_deref() == Some(field_name) { + let field_ty = registry + .resolve(field.ty.id) + .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; + if !matches!(field_ty.type_def, TypeDef::Primitive(TypeDefPrimitive::U32)) { + return Err(format!("field `{field_name}` is not a u32")); + } + return u32::decode(&mut input).map_err(|err| format!("field `{field_name}`: {err}")); + } + skip(registry, field.ty.id, &mut input)?; + } + Err(format!("field `{field_name}` not found")) +} + +/// Decode a SCALE compact-encoded length, advancing `input`. +fn read_compact(input: &mut &[u8]) -> Result { + let Compact(value) = Compact::::decode(input).map_err(|err| format!("compact: {err}"))?; + usize::try_from(value).map_err(|_| "compact length overflow".to_string()) +} + +/// Read one byte, advancing `input`. +fn read_u8(input: &mut &[u8]) -> Result { + let (&first, rest) = input + .split_first() + .ok_or_else(|| "unexpected end".to_string())?; + *input = rest; + Ok(first) +} + +/// Advance `input` by `n` bytes. +fn advance(input: &mut &[u8], n: usize) -> Result<(), String> { + if input.len() < n { + return Err(format!("need {n} bytes, have {}", input.len())); + } + *input = &input[n..]; + Ok(()) +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs new file mode 100644 index 00000000..f23125b1 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs @@ -0,0 +1,418 @@ +//! Signed-extension encoding for the unsigned General (v5) `AsResources` +//! extrinsic, driven by live chain metadata. +//! +//! The extension **order** and per-extension type ids come from the runtime +//! metadata (`state_getMetadata`, V14/V15); the per-extension `extra` / +//! `additional_signed` bytes come from a name-keyed encoder mirroring +//! signing-bot `src/core/create-transaction.ts` `encodeSignedExtensions`, with a +//! generic default for the personhood extensions (all `Option`/void). +//! +//! Two concatenations are derived from the same encoded list: +//! - the ring-VRF proof message (`build_proof_message`) over the extensions +//! strictly *after* `AsResources` (host-spec inherited implication), and +//! - the full extrinsic body's `Σ extra` (see `extrinsic.rs`), over *all* +//! extensions with `AsResources` carrying `Some(AsResourcesInfo)`. + +use std::collections::HashMap; + +use blake2_rfc::blake2b::blake2b; +use frame_metadata::RuntimeMetadata; +use frame_metadata::RuntimeMetadataPrefixed; +use parity_scale_codec::{Compact, Decode, Encode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + +/// Signed-extension identifier that carries the `AsResources` authorization. +pub const AS_RESOURCES: &str = "AsResources"; + +/// Chain state needed to fill the standard signed extensions. +#[derive(Debug, Clone, Copy)] +pub struct ChainState { + /// Runtime `specVersion` (CheckSpecVersion implicit). + pub spec_version: u32, + /// Runtime `transactionVersion` (CheckTxVersion implicit). + pub transaction_version: u32, + /// Genesis block hash (CheckGenesis / CheckMortality implicit). + pub genesis_hash: [u8; 32], + /// Account nonce (CheckNonce extra); ignored by the unsigned path. + pub nonce: u32, +} + +/// A signed extension's identifier plus the type ids of its `extra` and +/// `additional_signed` fields, in metadata order. +struct ExtensionDef { + identifier: String, + extra_type: u32, + additional_signed_type: u32, +} + +/// A signed extension encoded to its `extra` and `additional_signed` bytes. +pub struct EncodedExtension { + /// SCALE-encoded `extra` (goes into the extrinsic body). + pub extra: Vec, + /// SCALE-encoded `additional_signed` (the implicit, part of the signed data). + pub additional_signed: Vec, +} + +/// Decoded metadata: the ordered signed-extension defs, the type registry, and +/// each storage entry's value type id (`(pallet, entry) -> type id`). +pub struct Metadata { + extensions: Vec, + registry: PortableRegistry, + storage_values: HashMap<(String, String), u32>, + constants: HashMap<(String, String), Vec>, +} + +/// Collect extensions, type registry, storage value types, and pallet constants +/// from a decoded V14/V15 metadata; `$set` is the version's `StorageEntryType`. +macro_rules! collect_metadata { + ($m:expr, $set:path) => {{ + let extensions = $m + .extrinsic + .signed_extensions + .iter() + .map(|e| ExtensionDef { + identifier: e.identifier.clone(), + extra_type: e.ty.id, + additional_signed_type: e.additional_signed.id, + }) + .collect(); + let mut storage_values = HashMap::new(); + let mut constants = HashMap::new(); + for pallet in &$m.pallets { + for constant in &pallet.constants { + constants.insert( + (pallet.name.clone(), constant.name.clone()), + constant.value.clone(), + ); + } + let Some(storage) = &pallet.storage else { + continue; + }; + for entry in &storage.entries { + use $set as EntryType; + let value_type = match &entry.ty { + EntryType::Plain(ty) => ty.id, + EntryType::Map { value, .. } => value.id, + }; + storage_values.insert((pallet.name.clone(), entry.name.clone()), value_type); + } + } + (extensions, $m.types, storage_values, constants) + }}; +} + +macro_rules! collect_metadata_v16 { + ($m:expr) => {{ + let extension_indexes = $m + .extrinsic + .transaction_extensions_by_version + .get(&5) + .map(|indexes| { + indexes + .iter() + .map(|Compact(index)| *index as usize) + .collect::>() + }) + .unwrap_or_else(|| (0..$m.extrinsic.transaction_extensions.len()).collect()); + let extensions = extension_indexes + .into_iter() + .filter_map(|index| $m.extrinsic.transaction_extensions.get(index)) + .map(|e| ExtensionDef { + identifier: e.identifier.clone(), + extra_type: e.ty.id, + additional_signed_type: e.implicit.id, + }) + .collect(); + let mut storage_values = HashMap::new(); + let mut constants = HashMap::new(); + for pallet in &$m.pallets { + for constant in &pallet.constants { + constants.insert( + (pallet.name.clone(), constant.name.clone()), + constant.value.clone(), + ); + } + let Some(storage) = &pallet.storage else { + continue; + }; + for entry in &storage.entries { + use frame_metadata::v16::StorageEntryType as EntryType; + let value_type = match &entry.ty { + EntryType::Plain(ty) => ty.id, + EntryType::Map { value, .. } => value.id, + }; + storage_values.insert((pallet.name.clone(), entry.name.clone()), value_type); + } + } + (extensions, $m.types, storage_values, constants) + }}; +} + +impl Metadata { + /// Decode `state_getMetadata` bytes (a `RuntimeMetadataPrefixed`, V14 or + /// V15) into the ordered signed-extension defs, type registry, and storage + /// value types. + pub fn decode(bytes: &[u8]) -> Result { + let prefixed = RuntimeMetadataPrefixed::decode(&mut &bytes[..]) + .map_err(|err| format!("metadata decode failed: {err}"))?; + let (extensions, registry, storage_values, constants) = match prefixed.1 { + RuntimeMetadata::V14(m) => collect_metadata!(m, frame_metadata::v14::StorageEntryType), + RuntimeMetadata::V15(m) => collect_metadata!(m, frame_metadata::v15::StorageEntryType), + RuntimeMetadata::V16(m) => collect_metadata_v16!(m), + other => return Err(format!("unsupported metadata version {}", other.version())), + }; + Ok(Self { + extensions, + registry, + storage_values, + constants, + }) + } + + /// The type registry, for dynamic decoding of storage values. + pub fn registry(&self) -> &PortableRegistry { + &self.registry + } + + /// The value type id of storage entry `pallet::entry`, if present. + pub fn storage_value_type(&self, pallet: &str, entry: &str) -> Option { + self.storage_values + .get(&(pallet.to_string(), entry.to_string())) + .copied() + } + + /// The SCALE-encoded value bytes of pallet constant `pallet::name`. + pub fn constant(&self, pallet: &str, name: &str) -> Option<&[u8]> { + self.constants + .get(&(pallet.to_string(), name.to_string())) + .map(Vec::as_slice) + } + + /// Encode every signed extension in metadata order. + pub fn encode_signed_extensions(&self, state: &ChainState) -> Vec { + self.extensions + .iter() + .map(|ext| { + let (extra, additional_signed) = self.encode_one(ext, state); + EncodedExtension { + extra, + additional_signed, + } + }) + .collect() + } + + /// The signed-extension identifiers, in metadata order. + #[cfg(test)] + pub fn extension_ids(&self) -> Vec<&str> { + self.extensions + .iter() + .map(|e| e.identifier.as_str()) + .collect() + } + + /// Encode a single extension's `(extra, additional_signed)`, mirroring the + /// signing-bot switch; unknown personhood extensions fall back to the + /// metadata type default (`Option` -> None, void -> empty). + fn encode_one(&self, ext: &ExtensionDef, state: &ChainState) -> (Vec, Vec) { + match ext.identifier.as_str() { + "CheckNonce" => (Compact(state.nonce).encode(), Vec::new()), + "CheckSpecVersion" => (Vec::new(), state.spec_version.to_le_bytes().to_vec()), + "CheckTxVersion" => (Vec::new(), state.transaction_version.to_le_bytes().to_vec()), + "CheckGenesis" => (Vec::new(), state.genesis_hash.to_vec()), + // extra = Era::Immortal (0x00); implicit = genesis hash. + "CheckMortality" => (vec![0x00], state.genesis_hash.to_vec()), + // extra = first variant `Disabled` (void) = 0x00. + "VerifyMultiSignature" => (vec![0x00], Vec::new()), + // extra = { tip: compact(0), asset_id: None } = 0x00 0x00. + "ChargeAssetTxPayment" => (vec![0x00, 0x00], Vec::new()), + // extra = bool false = 0x00. + "RestrictOrigins" => (vec![0x00], Vec::new()), + _ => ( + self.encode_default(ext.extra_type), + self.encode_default(ext.additional_signed_type), + ), + } + } + + /// Encode the "disabled" default value for a metadata type: `Option` -> None + /// (`0x00`), void/empty tuple -> empty, enums -> first variant, primitives + /// -> zero. Matches signing-bot `defaultValueForType`. + fn encode_default(&self, type_id: u32) -> Vec { + let Some(ty) = self.registry.resolve(type_id) else { + return Vec::new(); + }; + match &ty.type_def { + TypeDef::Composite(c) => c + .fields + .iter() + .flat_map(|f| self.encode_default(f.ty.id)) + .collect(), + TypeDef::Tuple(t) => t + .fields + .iter() + .flat_map(|f| self.encode_default(f.id)) + .collect(), + TypeDef::Variant(v) => { + // Option encodes None as 0x00. + if ty.path.segments.last().map(String::as_str) == Some("Option") { + return vec![0x00]; + } + match v.variants.iter().min_by_key(|var| var.index) { + None => Vec::new(), + Some(first) => { + let mut out = vec![first.index]; + for field in &first.fields { + out.extend(self.encode_default(field.ty.id)); + } + out + } + } + } + TypeDef::Array(a) => { + let elem = self.encode_default(a.type_param.id); + elem.repeat(a.len as usize) + } + // Sequences / strings / bit-sequences encode an empty run as compact(0). + TypeDef::Sequence(_) | TypeDef::BitSequence(_) => vec![0x00], + TypeDef::Compact(_) => vec![0x00], + TypeDef::Primitive(p) => match p { + TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => vec![0], + TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => { + vec![0; 4] + } + TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => vec![0; 2], + TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => vec![0; 8], + TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => vec![0; 16], + TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => vec![0; 32], + // Length-prefixed string: empty = compact(0). + TypeDefPrimitive::Str => vec![0x00], + }, + } + } + + /// Index of `AsResources` in the extension list, if present. + pub fn as_resources_index(&self) -> Option { + self.extensions + .iter() + .position(|e| e.identifier == AS_RESOURCES) + } +} + +/// Build the ring-VRF proof message for an `AsResources`-authorized call: +/// `blake2b256(0x00 ‖ call ‖ Σ tail.extra ‖ Σ tail.additional_signed)`, where +/// the tail is the extensions ordered strictly after `AsResources`. The leading +/// `0x00` is the General-transaction extension-version byte. +pub fn build_proof_message( + metadata: &Metadata, + call_data: &[u8], + state: &ChainState, +) -> Result<[u8; 32], String> { + let all = metadata.encode_signed_extensions(state); + let tail_start = metadata + .as_resources_index() + .map(|i| i + 1) + .ok_or_else(|| format!("{AS_RESOURCES} extension not found in metadata"))?; + let tail = &all[tail_start..]; + + let mut payload = Vec::with_capacity(1 + call_data.len()); + payload.push(0x00); + payload.extend_from_slice(call_data); + for ext in tail { + payload.extend_from_slice(&ext.extra); + } + for ext in tail { + payload.extend_from_slice(&ext.additional_signed); + } + Ok(blake2b256(&payload)) +} + +/// BLAKE2b-256 of `message`. +pub fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b(32, &[], message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fixture metadata captured from paseo-next-v2 (raw `RuntimeMetadataPrefixed`). + const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + + /// The known-answer chain state frozen alongside the fixture. + fn fixture_state() -> ChainState { + ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + } + } + + /// `Resources.set_statement_store_account(period=7, seq=0, target=0)`. + fn fixture_call() -> Vec { + let mut call = vec![0x3f, 0x0a]; + call.extend_from_slice(&7u32.to_le_bytes()); + call.extend_from_slice(&0u32.to_le_bytes()); + call.extend_from_slice(&[0u8; 32]); + call + } + + #[test] + fn proof_message_matches_frozen_known_answer() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let msg = build_proof_message(&metadata, &fixture_call(), &fixture_state()).unwrap(); + assert_eq!( + hex::encode(msg), + "1d2e6d8d8f421b0857097c6076115507432d66fea47ebe0c3be282a369f6743c", + ); + } + + #[test] + fn as_resources_tail_is_indices_10_through_20() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let idx = metadata.as_resources_index().unwrap(); + // AsResources sits at index 9; the proof tail is everything after it. + assert_eq!(idx, 9); + let ids = metadata.extension_ids(); + assert_eq!( + ids[idx + 1..].to_vec(), + vec![ + "AuthorizeCall", + "RestrictOrigins", + "CheckNonZeroSender", + "CheckSpecVersion", + "CheckTxVersion", + "CheckGenesis", + "CheckMortality", + "CheckNonce", + "CheckWeight", + "ChargeAssetTxPayment", + "StorageWeightReclaim", + ], + ); + } + + #[test] + fn dropping_the_version_byte_changes_the_hash() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let state = fixture_state(); + let call = fixture_call(); + let all = metadata.encode_signed_extensions(&state); + let tail = &all[metadata.as_resources_index().unwrap() + 1..]; + let mut without = call.clone(); + for e in tail { + without.extend_from_slice(&e.extra); + } + for e in tail { + without.extend_from_slice(&e.additional_signed); + } + assert_ne!( + build_proof_message(&metadata, &call, &state).unwrap(), + blake2b256(&without), + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs new file mode 100644 index 00000000..a427e88a --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs @@ -0,0 +1,204 @@ +//! `Resources.set_statement_store_account` call + unsigned General (v5) +//! extrinsic assembly. Mirrors signing-bot `allocation.ts` / `extrinsic-submit.ts`. + +use parity_scale_codec::{Compact, Encode}; + +use super::extension::{ChainState, Metadata}; + +/// Pallet + call index for `Resources.set_statement_store_account` (63 / 10). +pub const SET_STATEMENT_STORE_ACCOUNT_CALL: [u8; 2] = [0x3f, 0x0a]; +/// Pallet + call index for `Resources.claim_long_term_storage` (63 / 12). +pub const CLAIM_LONG_TERM_STORAGE_CALL: [u8; 2] = [0x3f, 0x0c]; +/// `AsResourcesInfo::RegisterStatementStoreAllowance` variant index. +const REGISTER_STATEMENT_STORE_ALLOWANCE: u8 = 0x02; +/// `AsResourcesInfo::ClaimLongTermStorage` variant index. +const CLAIM_LONG_TERM_STORAGE: u8 = 0x03; +/// `MembershipCollection::LitePeople` variant index. +const MEMBERSHIP_COLLECTION_LITE_PEOPLE: u8 = 0x01; +/// General-transaction preamble byte: `0b01` (General) | version 5. +const GENERAL_V5_PREAMBLE: u8 = 0x45; +/// Current signed-extension version byte. +const EXTENSION_VERSION: u8 = 0x00; +/// `Option::Some` discriminant for the `AsResources` extension `extra`. +const OPTION_SOME: u8 = 0x01; + +/// Encode `Resources.set_statement_store_account(period, seq, target)`: +/// `3f 0a ‖ period_u32LE ‖ seq_u32LE ‖ target[32]`. +pub fn build_set_statement_store_account_call(period: u32, seq: u32, target: &[u8; 32]) -> Vec { + let mut call = Vec::with_capacity(2 + 4 + 4 + 32); + call.extend_from_slice(&SET_STATEMENT_STORE_ACCOUNT_CALL); + call.extend_from_slice(&period.to_le_bytes()); + call.extend_from_slice(&seq.to_le_bytes()); + call.extend_from_slice(target); + call +} + +/// Encode `Resources.claim_long_term_storage(period, counter, account_id)`: +/// `3f 0c ‖ period_u32LE ‖ counter_u8 ‖ account_id[32]`. +pub fn build_claim_long_term_storage_call( + period: u32, + counter: u8, + account_id: &[u8; 32], +) -> Vec { + let mut call = Vec::with_capacity(2 + 4 + 1 + 32); + call.extend_from_slice(&CLAIM_LONG_TERM_STORAGE_CALL); + call.extend_from_slice(&period.to_le_bytes()); + call.push(counter); + call.extend_from_slice(account_id); + call +} + +/// Encode the `AsResources` extension `extra` for a statement-store allowance: +/// `Some(RegisterStatementStoreAllowance { proof, ring_index, LitePeople })`. +pub fn build_as_resources_extra(proof: &[u8], ring_index: u32) -> Vec { + let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 1); + extra.push(OPTION_SOME); + extra.push(REGISTER_STATEMENT_STORE_ALLOWANCE); + extra.extend_from_slice(&Compact(proof.len() as u32).encode()); + extra.extend_from_slice(proof); + extra.extend_from_slice(&ring_index.to_le_bytes()); + extra.push(MEMBERSHIP_COLLECTION_LITE_PEOPLE); + extra +} + +/// Encode the `AsResources` extension `extra` for a long-term storage claim: +/// `Some(ClaimLongTermStorage { proof, ring_index, revision, LitePeople })`. +pub fn build_long_term_storage_extra(proof: &[u8], ring_index: u32, revision: u32) -> Vec { + let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 4 + 1); + extra.push(OPTION_SOME); + extra.push(CLAIM_LONG_TERM_STORAGE); + extra.extend_from_slice(&Compact(proof.len() as u32).encode()); + extra.extend_from_slice(proof); + extra.extend_from_slice(&ring_index.to_le_bytes()); + extra.extend_from_slice(&revision.to_le_bytes()); + extra.push(MEMBERSHIP_COLLECTION_LITE_PEOPLE); + extra +} + +/// Assemble the unsigned General (v5) extrinsic: +/// `compact(len) ‖ 0x45 ‖ 0x00 ‖ Σ(all extra, AsResources = Some(info)) ‖ call`. +pub fn build_unsigned_extrinsic( + metadata: &Metadata, + state: &ChainState, + call_data: &[u8], + as_resources_extra: &[u8], +) -> Result, String> { + let all = metadata.encode_signed_extensions(state); + let as_resources_index = metadata + .as_resources_index() + .ok_or_else(|| "AsResources extension not found in metadata".to_string())?; + + let mut body = vec![GENERAL_V5_PREAMBLE, EXTENSION_VERSION]; + for (i, ext) in all.iter().enumerate() { + if i == as_resources_index { + body.extend_from_slice(as_resources_extra); + } else { + body.extend_from_slice(&ext.extra); + } + } + body.extend_from_slice(call_data); + + let mut extrinsic = Compact(body.len() as u32).encode(); + extrinsic.extend_from_slice(&body); + Ok(extrinsic) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + + fn fixture_state() -> ChainState { + ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + } + } + + #[test] + fn call_layout_is_pallet_call_period_seq_target() { + let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); + assert_eq!( + call, + [ + vec![0x3f, 0x0a], + 7u32.to_le_bytes().to_vec(), + 0u32.to_le_bytes().to_vec(), + vec![0u8; 32], + ] + .concat() + ); + } + + #[test] + fn long_term_storage_call_layout_is_pallet_call_period_counter_account() { + let call = build_claim_long_term_storage_call(7, 3, &[0u8; 32]); + assert_eq!( + call, + [ + vec![0x3f, 0x0c], + 7u32.to_le_bytes().to_vec(), + vec![3], + vec![0u8; 32], + ] + .concat() + ); + } + + #[test] + fn as_resources_extra_wraps_proof_as_bytes() { + let proof = vec![0xEE; 785]; + let extra = build_as_resources_extra(&proof, 3); + // Some(0x01) ‖ variant(0x02) ‖ compact(785)=0x45,0x0c ‖ 785 bytes ‖ ringIndex LE ‖ LitePeople. + assert_eq!(&extra[..2], &[0x01, 0x02]); + assert_eq!(&extra[2..4], &Compact(785u32).encode()[..]); + assert_eq!(&extra[4..4 + 785], &proof[..]); + assert_eq!(&extra[4 + 785..4 + 785 + 4], &3u32.to_le_bytes()); + assert_eq!(extra[4 + 785 + 4], MEMBERSHIP_COLLECTION_LITE_PEOPLE); + } + + #[test] + fn long_term_storage_extra_wraps_revision() { + let proof = vec![0xEE; 785]; + let extra = build_long_term_storage_extra(&proof, 3, 9); + // Some(0x01) ‖ variant(0x03) ‖ compact(785)=0x45,0x0c ‖ proof + // ‖ ringIndex LE ‖ revision LE ‖ LitePeople. + assert_eq!(&extra[..2], &[0x01, 0x03]); + assert_eq!(&extra[2..4], &Compact(785u32).encode()[..]); + assert_eq!(&extra[4..4 + 785], &proof[..]); + assert_eq!(&extra[4 + 785..4 + 785 + 4], &3u32.to_le_bytes()); + assert_eq!(&extra[4 + 785 + 4..4 + 785 + 8], &9u32.to_le_bytes()); + assert_eq!(extra[4 + 785 + 8], MEMBERSHIP_COLLECTION_LITE_PEOPLE); + } + + #[test] + fn extrinsic_has_general_v5_preamble_and_embeds_call() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); + let extra = build_as_resources_extra(&vec![0xEE; 785], 0); + let xt = build_unsigned_extrinsic(&metadata, &fixture_state(), &call, &extra).unwrap(); + + // Strip the compact length prefix and check the body head + tail. + let body = &xt[compact_prefix_len(&xt)..]; + assert_eq!(&body[..2], &[GENERAL_V5_PREAMBLE, EXTENSION_VERSION]); + assert_eq!(&body[body.len() - call.len()..], &call[..]); + // The Some(info) extra appears verbatim in the body. + assert!( + body.windows(extra.len()).any(|w| w == extra), + "AsResources Some(info) extra should appear in the body", + ); + } + + /// Length of the SCALE compact prefix at the head of `xt`. + fn compact_prefix_len(xt: &[u8]) -> usize { + match xt[0] & 0b11 { + 0b00 => 1, + 0b01 => 2, + 0b10 => 4, + _ => 5, + } + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs new file mode 100644 index 00000000..b9dc567f --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs @@ -0,0 +1,111 @@ +//! Bandersnatch ring-VRF one-shot membership proof (prover side). +//! +//! Wraps `verifiable`'s prover-gated `open` + `create` into the single-shot +//! proof a `RegisterStatementStoreAllowance` needs: prove that our member key is +//! in the LitePeople ring, bound to a slot `context` and the extrinsic proof +//! `message`. Mirrors signing-bot `ring-proof.ts` `oneShotProof`. + +use verifiable::GenerateVerifiable; +use verifiable::ring::RingDomainSize; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +/// A single-context ring-VRF signature is exactly 785 bytes. +pub const RING_VRF_PROOF_LEN: usize = 785; + +/// Map an on-chain `RingExponent` (9 / 10 / 14) to the FFT domain size +/// (power = exponent + 2). +pub fn domain_for_ring_exponent(exponent: u8) -> Result { + match exponent { + 9 => Ok(RingDomainSize::Domain11), + 10 => Ok(RingDomainSize::Domain12), + 14 => Ok(RingDomainSize::Domain16), + other => Err(format!("unsupported ring exponent {other}")), + } +} + +/// The ring member key for a bandersnatch entropy (`blake2b256(bip39_entropy)`). +pub fn member_key(entropy: [u8; 32]) -> [u8; 32] { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + BandersnatchVrfVerifiable::member_from_secret(&secret) +} + +/// Produce the 785-byte ring-VRF membership proof over `members` (already +/// sliced to the ring's included prefix), bound to `context` and `message`. +/// +/// `entropy` is the bandersnatch entropy; its member key must be present in +/// `members` or `open` fails with `NotInRing`. +pub fn ring_vrf_proof( + domain: RingDomainSize, + entropy: [u8; 32], + members: &[[u8; 32]], + context: &[u8], + message: &[u8], +) -> Result, String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let member = BandersnatchVrfVerifiable::member_from_secret(&secret); + let commitment = BandersnatchVrfVerifiable::open(domain, &member, members.iter().copied()) + .map_err(|err| format!("ring-VRF open failed: {err:?}"))?; + let (proof, _alias) = BandersnatchVrfVerifiable::create(commitment, &secret, context, message) + .map_err(|err| format!("ring-VRF create failed: {err:?}"))?; + let bytes = proof.into_inner(); + if bytes.len() != RING_VRF_PROOF_LEN { + return Err(format!( + "ring-VRF proof is {} bytes, expected {RING_VRF_PROOF_LEN}", + bytes.len() + )); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exponent_maps_to_domain() { + assert_eq!( + domain_for_ring_exponent(9).unwrap(), + RingDomainSize::Domain11 + ); + assert_eq!( + domain_for_ring_exponent(10).unwrap(), + RingDomainSize::Domain12 + ); + assert_eq!( + domain_for_ring_exponent(14).unwrap(), + RingDomainSize::Domain16 + ); + assert!(domain_for_ring_exponent(11).is_err()); + } + + #[test] + fn proof_is_785_bytes_for_a_single_member_ring() { + let entropy = [0x11u8; 32]; + let member = member_key(entropy); + let members = vec![member]; + let proof = ring_vrf_proof( + RingDomainSize::Domain11, + entropy, + &members, + b"SSS_SLOT:test-context-padding..", + &[0x42; 32], + ) + .unwrap(); + assert_eq!(proof.len(), RING_VRF_PROOF_LEN); + } + + #[test] + fn open_fails_when_member_absent_from_ring() { + let entropy = [0x11u8; 32]; + let other = member_key([0x22u8; 32]); + let err = ring_vrf_proof( + RingDomainSize::Domain11, + entropy, + &[other], + b"SSS_SLOT:test-context-padding..", + &[0x42; 32], + ) + .unwrap_err(); + assert!(err.contains("open failed"), "unexpected error: {err}"); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs new file mode 100644 index 00000000..a461dcac --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs @@ -0,0 +1,197 @@ +//! LitePeople ring parameters from the People chain (`Members` pallet). +//! +//! Reads the on-chain ring so the membership proof is built against the same +//! members the runtime verifies against: the baked-in `included` prefix of the +//! current ring. Mirrors signing-bot `ring-proof.ts`. + +use parity_scale_codec::{Compact, Decode}; +use sp_crypto_hashing::{blake2_128, twox_64, twox_128}; + +use super::dynamic::{read_field_u32, read_field_variant_name}; +use super::extension::Metadata; +use super::rpc::RpcClient; + +/// LitePeople collection identifier: ASCII, exactly 32 bytes. +const LITE_PEOPLE_IDENTIFIER: &[u8; 32] = b"pop:polkadot.network/people-lite"; +/// Ring member public key length. +const MEMBER_LEN: usize = 32; + +/// On-chain LitePeople ring parameters for building a verifying proof. +pub struct RingParams { + /// Ring members, sliced to the baked-in `included` prefix. + pub members: Vec<[u8; 32]>, + /// Ring size exponent (9 / 10 / 14). + pub exponent: u8, + /// Ring index these members belong to. + pub ring_index: u32, +} + +/// `Members.CurrentRingIndex[id]` storage key. +fn current_ring_index_key() -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"CurrentRingIndex").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + ] + .concat() +} + +/// `Members.Collections[id]` storage key. +fn collections_key() -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Collections").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + ] + .concat() +} + +/// `Members.RingKeysStatus[(id, ring_index)]` storage key. +fn ring_keys_status_key(ring_index: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeysStatus").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + ] + .concat() +} + +/// `Members.Root[(id, ring_index)]` storage key. +fn ring_root_key(ring_index: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Root").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + ] + .concat() +} + +/// `Members.RingKeys[(id, ring_index, page)]` storage key. +fn ring_keys_key(ring_index: u32, page: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeys").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + &twox_64_concat(&page.to_le_bytes()), + ] + .concat() +} + +/// `Blake2_128Concat(x)` = `blake2_128(x) ‖ x`. +pub(super) fn blake2_128_concat(x: &[u8]) -> Vec { + [blake2_128(x).as_slice(), x].concat() +} + +/// `Twox64Concat(x)` = `twox_64(x) ‖ x`. +fn twox_64_concat(x: &[u8]) -> Vec { + [twox_64(x).as_slice(), x].concat() +} + +/// Map a `RingExponent` variant name to its exponent. +fn ring_exponent_from_name(name: &str) -> Result { + match name { + "R2e9" => Ok(9), + "R2e10" => Ok(10), + "R2e14" => Ok(14), + other => Err(format!("unsupported RingExponent variant `{other}`")), + } +} + +/// Read the current LitePeople ring index (absent => 0). +pub async fn read_current_ring_index(rpc: &RpcClient) -> Result { + match rpc + .get_storage(¤t_ring_index_key()) + .await + .map_err(|e| e.to_string())? + { + Some(bytes) => u32::decode(&mut &bytes[..]).map_err(|e| format!("ring index: {e}")), + None => Ok(0), + } +} + +/// Read the LitePeople ring size exponent from `Collections[LitePeople].ring_size`. +/// This is a chain constant, so read it once and reuse across ring indices. +pub async fn read_ring_exponent(rpc: &RpcClient, metadata: &Metadata) -> Result { + let collection = rpc + .get_storage(&collections_key()) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Members.Collections[LitePeople] missing".to_string())?; + let value_type = metadata + .storage_value_type("Members", "Collections") + .ok_or_else(|| "Members.Collections type not in metadata".to_string())?; + let variant = + read_field_variant_name(metadata.registry(), value_type, "ring_size", &collection)?; + ring_exponent_from_name(&variant) +} + +/// Read the members of `ring_index`, sliced to the baked-in `included` prefix. +pub async fn read_ring_members_at( + rpc: &RpcClient, + ring_index: u32, +) -> Result, String> { + // 1. Page through RingKeys collecting raw 32-byte members. + let mut members = Vec::new(); + for page in 0.. { + let Some(bytes) = rpc + .get_storage(&ring_keys_key(ring_index, page)) + .await + .map_err(|e| e.to_string())? + else { + break; + }; + let mut cursor = &bytes[..]; + let Compact(len) = + Compact::::decode(&mut cursor).map_err(|e| format!("ring keys len: {e}"))?; + if len == 0 { + break; + } + for i in 0..len as usize { + let start = i * MEMBER_LEN; + let member: [u8; 32] = cursor + .get(start..start + MEMBER_LEN) + .ok_or_else(|| "ring keys page truncated".to_string())? + .try_into() + .expect("slice is 32 bytes"); + members.push(member); + } + } + + // 2. Slice to the baked-in `included` prefix (absent status => all included). + if let Some(status) = rpc + .get_storage(&ring_keys_status_key(ring_index)) + .await + .map_err(|e| e.to_string())? + { + // RingStatus = { total: u32 LE, included: u32 LE, .. }. + let included = u32::decode(&mut &status[4..]).map_err(|e| format!("ring status: {e}"))?; + members.truncate(included as usize); + } + + Ok(members) +} + +/// Read `Members.Root[LitePeople][ring_index].revision` (absent => 0). +pub async fn read_ring_revision( + rpc: &RpcClient, + metadata: &Metadata, + ring_index: u32, +) -> Result { + match rpc + .get_storage(&ring_root_key(ring_index)) + .await + .map_err(|e| e.to_string())? + { + Some(bytes) => { + let value_type = metadata + .storage_value_type("Members", "Root") + .ok_or_else(|| "Members.Root type not in metadata".to_string())?; + read_field_u32(metadata.registry(), value_type, "revision", &bytes) + .map_err(|e| format!("ring revision: {e}")) + } + None => Ok(0), + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs new file mode 100644 index 00000000..870a5db1 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs @@ -0,0 +1,121 @@ +//! Host-backed JSON-RPC helpers for statement-store allowance registration. + +use core::time::Duration; + +use futures::{FutureExt, pin_mut}; +use serde_json::Value; +use subxt_rpcs::RpcClient as HostRpcClient; +use subxt_rpcs::client::{RpcParams, rpc_params}; + +/// Timeout for an allowance registration extrinsic to finalize. +const SUBMIT_TIMEOUT: Duration = Duration::from_secs(120); + +/// Thin adapter matching the allowance allocator's minimal RPC surface. +#[derive(Clone)] +pub struct RpcClient { + inner: HostRpcClient, +} + +impl RpcClient { + /// Wrap a platform-backed Subxt RPC client. + pub fn new(inner: HostRpcClient) -> Self { + Self { inner } + } + + /// Call `method` with JSON-array `params`, returning the result value. + pub async fn call(&self, method: &str, params: Value) -> Result { + self.inner + .request(method, value_to_params(params)?) + .await + .map_err(rpc_error_message) + } + + /// `state_getStorage(key)` -> raw value bytes, or `None` if absent. + pub async fn get_storage(&self, key: &[u8]) -> Result>, String> { + let key_hex = format!("0x{}", hex::encode(key)); + match self + .inner + .request::("state_getStorage", rpc_params![key_hex]) + .await + .map_err(rpc_error_message)? + { + Value::String(hex_value) => Ok(Some(decode_hex(&hex_value)?)), + _ => Ok(None), + } + } + + /// Submit an extrinsic and wait for `finalized`; returns the block hash. + pub async fn submit_and_watch(&self, extrinsic: &[u8]) -> Result { + let extrinsic_hex = format!("0x{}", hex::encode(extrinsic)); + let mut subscription = self + .inner + .subscribe::( + "author_submitAndWatchExtrinsic", + rpc_params![extrinsic_hex], + "author_unwatchExtrinsic", + ) + .await + .map_err(rpc_error_message)?; + let timeout = futures_timer::Delay::new(SUBMIT_TIMEOUT).fuse(); + pin_mut!(timeout); + + loop { + let next = subscription.next().fuse(); + pin_mut!(next); + let status = futures::select! { + item = next => item.ok_or_else(|| { + "author_submitAndWatchExtrinsic subscription ended".to_string() + })?.map_err(rpc_error_message)?, + () = timeout => return Err( + "timed out waiting for author_submitAndWatchExtrinsic finalization".to_string() + ), + }; + match extrinsic_status(&status) { + ExtrinsicStatus::Finalized(hash) => return Ok(hash), + ExtrinsicStatus::Rejected(reason) => return Err(format!("extrinsic {reason}")), + ExtrinsicStatus::Pending => {} + } + } + } +} + +enum ExtrinsicStatus { + Finalized(String), + Rejected(String), + Pending, +} + +fn extrinsic_status(status: &Value) -> ExtrinsicStatus { + if let Some(hash) = status.get("finalized").and_then(Value::as_str) { + return ExtrinsicStatus::Finalized(hash.to_string()); + } + for key in ["invalid", "dropped", "usurped", "finalityTimeout"] { + if status.get(key).is_some() { + return ExtrinsicStatus::Rejected(key.to_string()); + } + } + ExtrinsicStatus::Pending +} + +fn value_to_params(value: Value) -> Result { + let Value::Array(values) = value else { + return Err("RPC params must be a JSON array".to_string()); + }; + let mut params = RpcParams::new(); + for value in values { + params.push(value).map_err(rpc_error_message)?; + } + Ok(params) +} + +fn decode_hex(value: &str) -> Result, String> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)) + .map_err(|err| format!("decode hex storage value: {err}")) +} + +fn rpc_error_message(error: subxt_rpcs::Error) -> String { + match error { + subxt_rpcs::Error::User(error) => error.message, + other => other.to_string(), + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs new file mode 100644 index 00000000..a9a99b81 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -0,0 +1,247 @@ +//! StatementStore allowance slot selection. +//! +//! An allowance is claimed at `(period, seq)`. The slot is bound to a 32-byte +//! `SSS_SLOT` context; occupancy is read from +//! `Resources.StatementStoreAllowances[period][alias]`, where the alias is +//! derived from OUR bandersnatch entropy in that slot context. Mirrors +//! signing-bot `allowance.ts` / `allowance-slots.ts`. + +use sp_crypto_hashing::twox_128; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use super::extension::Metadata; +use super::ring::blake2_128_concat; +use super::rpc::RpcClient; + +/// StatementStore allowance period: one UTC day, in seconds. +pub const STATEMENT_STORE_PERIOD_SECONDS: u64 = 86_400; +/// Bulletin long-term-storage claim context prefix. +const LONG_TERM_STORAGE_CONTEXT_PREFIX: &[u8] = b"pop:polkadot.net/rsc-lts"; + +/// The current allowance period for `now_seconds`. +pub fn current_period(now_seconds: u64) -> u32 { + (now_seconds / STATEMENT_STORE_PERIOD_SECONDS) as u32 +} + +/// The current long-term-storage period for `now_seconds`. +pub fn current_long_term_storage_period( + now_seconds: u64, + period_duration: u32, +) -> Result { + if period_duration == 0 { + return Err("Resources.LongTermStoragePeriodDuration is zero".to_string()); + } + Ok((now_seconds / u64::from(period_duration)) as u32) +} + +/// Derive the 32-byte StatementStore slot context: +/// `"SSS_SLOT:" ‖ u32be(period) ‖ u32be(seq) ‖ 0x20 fill`. +pub fn derive_slot_context(period: u32, seq: u32) -> [u8; 32] { + let mut ctx = [0x20u8; 32]; + ctx[..9].copy_from_slice(b"SSS_SLOT:"); + ctx[9..13].copy_from_slice(&period.to_be_bytes()); + ctx[13..17].copy_from_slice(&seq.to_be_bytes()); + ctx +} + +/// Derive the 32-byte Bulletin long-term-storage slot context: +/// `"pop:polkadot.net/rsc-lts" ‖ u32be(period) ‖ counter ‖ zero fill`. +pub fn derive_long_term_storage_context(period: u32, counter: u8) -> [u8; 32] { + let mut ctx = [0u8; 32]; + ctx[..LONG_TERM_STORAGE_CONTEXT_PREFIX.len()].copy_from_slice(LONG_TERM_STORAGE_CONTEXT_PREFIX); + let offset = LONG_TERM_STORAGE_CONTEXT_PREFIX.len(); + ctx[offset..offset + 4].copy_from_slice(&period.to_be_bytes()); + ctx[offset + 4] = counter; + ctx +} + +/// The slot alias for our `entropy` at `(period, seq)`. +pub fn slot_alias(entropy: [u8; 32], period: u32, seq: u32) -> Result<[u8; 32], String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let context = derive_slot_context(period, seq); + BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("alias_in_context failed: {err:?}")) +} + +/// The long-term-storage slot alias for our `entropy` at `(period, counter)`. +pub fn long_term_storage_alias( + entropy: [u8; 32], + period: u32, + counter: u8, +) -> Result<[u8; 32], String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let context = derive_long_term_storage_context(period, counter); + BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("alias_in_context failed: {err:?}")) +} + +/// `Resources.StatementStoreAllowances[period][alias]` storage key. +/// key1 = Identity(u32be period); key2 = Blake2_128Concat(alias). +fn statement_store_allowance_key(period: u32, alias: &[u8; 32]) -> Vec { + [ + twox_128(b"Resources").as_slice(), + twox_128(b"StatementStoreAllowances").as_slice(), + &period.to_be_bytes(), + &blake2_128_concat(alias), + ] + .concat() +} + +/// `Resources.SpentLongTermStorageAliases[period][alias]` storage key. +/// key1 = Identity(u32be period); key2 = Blake2_128Concat(alias). +fn spent_long_term_storage_alias_key(period: u32, alias: &[u8; 32]) -> Vec { + [ + twox_128(b"Resources").as_slice(), + twox_128(b"SpentLongTermStorageAliases").as_slice(), + &period.to_be_bytes(), + &blake2_128_concat(alias), + ] + .concat() +} + +/// Max StatementStore slots per period from `Resources.LiteStmtStoreSlotsPerPeriod`. +fn max_slots(metadata: &Metadata) -> Result { + let bytes = metadata + .constant("Resources", "LiteStmtStoreSlotsPerPeriod") + .ok_or_else(|| "Resources.LiteStmtStoreSlotsPerPeriod constant missing".to_string())?; + let mut buf = [0u8; 4]; + let n = bytes.len().min(4); + buf[..n].copy_from_slice(&bytes[..n]); + Ok(u32::from_le_bytes(buf)) +} + +/// Max long-term-storage claims per period from +/// `Resources.LongTermStorageClaimsPerPeriod`. +fn long_term_storage_claims_per_period(metadata: &Metadata) -> Result { + metadata + .constant("Resources", "LongTermStorageClaimsPerPeriod") + .and_then(|bytes| bytes.first().copied()) + .ok_or_else(|| "Resources.LongTermStorageClaimsPerPeriod constant missing".to_string()) +} + +/// Long-term-storage period duration in seconds from +/// `Resources.LongTermStoragePeriodDuration`. +pub fn long_term_storage_period_duration(metadata: &Metadata) -> Result { + let bytes = metadata + .constant("Resources", "LongTermStoragePeriodDuration") + .ok_or_else(|| "Resources.LongTermStoragePeriodDuration constant missing".to_string())?; + let mut buf = [0u8; 4]; + let n = bytes.len().min(4); + buf[..n].copy_from_slice(&bytes[..n]); + Ok(u32::from_le_bytes(buf)) +} + +/// The account id occupying a slot entry, if the storage value is present. +/// Entry = `account_id(32) ‖ seq(u32 LE) ‖ since(u64 LE)`. +fn entry_account_id(bytes: &[u8]) -> Option<[u8; 32]> { + bytes.get(..32).map(|s| s.try_into().expect("32 bytes")) +} + +/// Outcome of scanning for a slot to register `target` in. +pub enum SlotSelection { + /// A free `seq` we should claim. + Free(u32), + /// `target` already holds `seq` this period; no registration needed. + AlreadyAllocated(u32), +} + +/// Scan slots `0..max` for `period`, returning the first non-excluded free seq +/// (or detecting that `target` already holds one). `entropy` is our +/// bandersnatch entropy. +pub async fn scan_slot_excluding( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + period: u32, + target: &[u8; 32], + excluded: &[u32], +) -> Result { + let max = max_slots(metadata)?; + let mut first_free: Option = None; + for seq in 0..max { + let alias = slot_alias(entropy, period, seq)?; + let key = statement_store_allowance_key(period, &alias); + match rpc.get_storage(&key).await.map_err(|e| e.to_string())? { + None => { + if first_free.is_none() && !excluded.contains(&seq) { + first_free = Some(seq); + } + } + Some(bytes) => { + if entry_account_id(&bytes) == Some(*target) { + return Ok(SlotSelection::AlreadyAllocated(seq)); + } + } + } + } + first_free + .map(SlotSelection::Free) + .ok_or_else(|| format!("no free StatementStore slot in period {period} (max {max})")) +} + +/// Scan long-term-storage aliases `0..max` for `period`, returning the first +/// free counter not listed in `excluded`. `entropy` is our bandersnatch entropy. +pub async fn scan_long_term_storage_counter_excluding( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + period: u32, + excluded: &[u8], +) -> Result { + let max = long_term_storage_claims_per_period(metadata)?; + for counter in 0..max { + if excluded.contains(&counter) { + continue; + } + let alias = long_term_storage_alias(entropy, period, counter)?; + let key = spent_long_term_storage_alias_key(period, &alias); + if rpc + .get_storage(&key) + .await + .map_err(|e| e.to_string())? + .is_none() + { + return Ok(counter); + } + } + Err(format!( + "no free long-term-storage slot in period {period} (max {max})" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slot_context_layout() { + let ctx = derive_slot_context(7, 3); + assert_eq!(&ctx[..9], b"SSS_SLOT:"); + assert_eq!(&ctx[9..13], &7u32.to_be_bytes()); + assert_eq!(&ctx[13..17], &3u32.to_be_bytes()); + assert!(ctx[17..].iter().all(|&b| b == 0x20)); + } + + #[test] + fn long_term_storage_context_layout() { + let ctx = derive_long_term_storage_context(7, 3); + assert_eq!(&ctx[..24], b"pop:polkadot.net/rsc-lts"); + assert_eq!(&ctx[24..28], &7u32.to_be_bytes()); + assert_eq!(ctx[28], 3); + assert!(ctx[29..].iter().all(|&b| b == 0)); + } + + #[test] + fn period_is_utc_day_index() { + assert_eq!(current_period(86_400 * 20_000 + 5), 20_000); + } + + #[test] + fn long_term_storage_period_uses_chain_duration() { + assert_eq!( + current_long_term_storage_period(1_209_600 * 20 + 5, 1_209_600).unwrap(), + 20, + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index c2607a37..34297aaf 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -384,7 +384,7 @@ mod tests { use super::super::{LocalActivation, RuntimeServices, SigningHostRole}; use super::*; use crate::host_logic::product_account::{ - SR25519_SIGNING_CONTEXT, derive_product_keypair, derive_root_keypair_from_entropy, + SR25519_SIGNING_CONTEXT, derive_product_keypair, derive_sr25519_hard_path, }; use crate::test_support::{ StubPlatform, account_id, new_statements_frame, runtime_config, signed_statement, @@ -419,8 +419,8 @@ 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 signing_host = SigningHostRole::new(platform); + let services = RuntimeServices::new(platform.clone(), [0; 32], [0xbb; 32], test_spawner()); + let signing_host = SigningHostRole::new(platform, services.clone()); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); let host = ProductRuntimeHost::from_services( @@ -432,27 +432,70 @@ mod tests { } #[test] - fn statement_store_create_proof_pairing_host_does_not_use_session_key() { - let host = - ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); - host.test_session_state().set_session(sso_session_info()); + fn statement_store_create_proof_pairing_host_routes_exact_signing_over_sso() { + let mut session = sso_session_info(); + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + session.public_key = wallet.public.to_bytes(); + let statement = statement(); + let payload = statement_payload(statement.clone()); + let product_keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); + let expected_signer = product_keypair.public.to_bytes(); + let signature = product_keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &payload, &product_keypair.public) + .to_bytes(); + let platform = Arc::new(StubPlatform { + sso_response_script: Some(sso_success_response_script( + &session, + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-proof-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::StatementStoreProductSignResponse( + crate::host_logic::sso::messages::StatementStoreProductSignResponse { + responding_to: "proof-1".to_string(), + signature: Ok(signature.to_vec()), + }, + ), + ), + }, + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); let cx = CallContext::new(); let request = RemoteStatementStoreCreateProofRequest::V1( v01::RemoteStatementStoreCreateProofRequest { product_account_id: account_id("myapp.dot", 0), - statement: statement(), + statement, }, ); - let err = futures::executor::block_on(StatementStore::create_proof(&host, &cx, request)) - .unwrap_err(); + let response = + futures::executor::block_on(StatementStore::create_proof(&host, &cx, request)).unwrap(); - assert!(matches!( - err, - CallError::Domain(RemoteStatementStoreCreateProofError::V1( - v01::RemoteStatementStoreCreateProofError::UnableToSign - )) - )); + let RemoteStatementStoreCreateProofResponse::V1(inner) = response; + let v01::StatementProof::Sr25519 { signer, signature } = inner.proof else { + panic!("expected sr25519 statement proof"); + }; + assert_eq!(signer, expected_signer); + assert_sr25519_signature(signer, signature, &payload); + + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::StatementStoreProductSignRequest( + request, + ), + ) = message.data + else { + panic!("expected statement-store product sign request"); + }; + assert_eq!(request.product_account_id, account_id("myapp.dot", 0)); + assert_eq!(request.payload, payload); } #[test] @@ -460,8 +503,8 @@ mod tests { let (host, _signing_host) = signing_host_runtime("myapp.dot"); let statement = statement(); let payload = statement_payload(statement.clone()); - let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let product_keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let product_keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); let expected_signer = product_keypair.public.to_bytes(); let cx = CallContext::new(); let request = RemoteStatementStoreCreateProofRequest::V1( @@ -577,7 +620,9 @@ mod tests { #[test] fn statement_store_submit_posts_signed_statement_and_waits_for_ack() { let platform = Arc::new(StubPlatform { - rpc_responses: vec![r#"{"jsonrpc":"2.0","id":"truapi:1","result":"0xok"}"#.to_string()], + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":{"status":"new"}}"#.to_string(), + ], ..Default::default() }); let host = ProductRuntimeHost::new( @@ -850,9 +895,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..a3dc132b 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; @@ -103,16 +109,24 @@ pub(super) async fn subscribe_match_all( subscribe(rpc_client, TopicFilterKind::MatchAll, topics).await } -/// Submit a SCALE-encoded statement and wait for the JSON-RPC ack. +/// Submit a SCALE-encoded statement and confirm the store accepted it. +/// +/// `statement_submit` returns an RPC error only for internal failures; a +/// rejected or invalid statement (e.g. `NoAllowance`, `BadProof`) comes back as +/// `Ok(SubmitResult)`. Treat only `new`/`known` as success, so allowance/proof +/// rejections surface instead of being silently dropped. pub(super) async fn submit(rpc_client: &RpcClient, statement: Vec) -> Result<(), String> { - rpc_client + let result = rpc_client .request::( SUBMIT_STATEMENT_METHOD, rpc_params![format!("0x{}", hex::encode(&statement))], ) .await - .map(|_| ()) - .map_err(rpc_error_message) + .map_err(rpc_error_message)?; + match result.get("status").and_then(Value::as_str) { + Some("new") | Some("known") => Ok(()), + _ => Err(format!("statement_submit not accepted: {result}")), + } } /// Statement-store topic filter encoded as JSON-RPC params. diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 652b4e41..0963dc5d 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -33,7 +33,7 @@ use truapi::v01; use truapi::versioned::account::HostAccountGetAliasRequest; use truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest; use truapi_platform::{ - AccountAccessReview, AuthPresenter, AuthState, BulletinAllowanceSigner, ChainProvider, + AccountAccessReview, AuthPresenter, AuthState, ChainProvider, CoreStorage as PlatformCoreStorage, CoreStorageKey, Features as PlatformFeatures, HostInfo, JsonRpcConnection, Navigation as PlatformNavigation, Notifications as PlatformNotifications, PairingHostConfig, Permissions as PlatformPermissions, PlatformInfo, PreimageHost, @@ -96,9 +96,6 @@ pub(crate) struct StubPlatform { /// Invoked after each recorded auth state, outside any stub lock, so a /// test can react to a transition (e.g. cancel the login it observes). pub(crate) on_auth_state: Arc>>, - /// Set when a `chain_connect_pending` connect future is dropped, which is - /// how a dropped login flow manifests on the stub. - pub(crate) pending_connect_dropped: Arc, /// When true, `subscribe_theme` returns a never-ending stream. pub(crate) theme_stream_pending: bool, /// Set when the pending theme stream is dropped. @@ -115,11 +112,15 @@ pub(crate) struct StubPlatform { pub(crate) sent_rpc: Arc>>, pub(crate) rpc_responses: Vec, pub(crate) sso_response_script: Option, + /// When set, `connect` fails with this reason. pub(crate) chain_connect_error: Option<&'static str>, + /// When true, `connect` stays pending forever. pub(crate) chain_connect_pending: bool, - pub(crate) preimage_submits: Arc>>>, - pub(crate) preimage_submit_allowance_public_keys: Arc>>>, - pub(crate) preimage_submit_signatures: Arc>>>, + /// Set when a `chain_connect_pending` connect future is dropped. + pub(crate) pending_connect_dropped: Arc, + /// Value returned by `lookup_preimage`, if any. Tests set this to a + /// forged value to exercise the in-core integrity check. + pub(crate) preimage_lookup_value: Option>, pub(crate) local_storage: Arc>>>, /// When set, product/core storage reads fail with this reason. pub(crate) local_storage_error: Option<&'static str>, @@ -136,14 +137,6 @@ pub(crate) enum SsoResponseScript { }, } -struct DropFlagGuard(Arc); - -impl Drop for DropFlagGuard { - fn drop(&mut self) { - self.0.store(true, Ordering::SeqCst); - } -} - struct PendingThemeStream { dropped: Arc, } @@ -193,6 +186,7 @@ pub(crate) fn runtime_config(product_id: &str) -> (PairingHostConfig, ProductCon }, PlatformInfo::default(), [0; 32], + [0xbb; 32], "polkadotapp".to_string(), ) .expect("test host runtime config is valid"), @@ -426,10 +420,12 @@ pub(crate) fn subscribe_ack_frame(request_id: &str, subscription_id: &str) -> St } fn statement_submit_ack_frame(request_id: &str) -> String { + // Mirror the real `statement_submit` result shape (`SubmitResult`); `submit` + // treats only `new`/`known` as accepted. serde_json::json!({ "jsonrpc": "2.0", "id": request_id, - "result": "0xok", + "result": { "status": "new" }, }) .to_string() } @@ -923,6 +919,9 @@ fn retarget_sso_response(mut response: RemoteMessage, message_id: &str) -> Remot RemoteMessageData::V1(v1::RemoteMessage::CreateTransactionResponse(response)) => { response.responding_to = message_id.to_string(); } + RemoteMessageData::V1(v1::RemoteMessage::StatementStoreProductSignResponse(response)) => { + response.responding_to = message_id.to_string(); + } _ => {} } response @@ -1187,6 +1186,15 @@ fn json_rpc_id(frame: &str) -> Option { } } +/// Sets its flag when dropped, marking a cancelled pending operation. +struct DropFlagGuard(Arc); + +impl Drop for DropFlagGuard { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::SeqCst); + } +} + #[truapi_platform::async_trait] impl ChainProvider for StubPlatform { async fn connect( @@ -1292,32 +1300,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 new file mode 100644 index 00000000..195ea379 --- /dev/null +++ b/rust/crates/truapi-server/src/wasm.rs @@ -0,0 +1,918 @@ +//! wasm-bindgen surface. Exposes [`WasmProductRuntime`] to JavaScript hosts so +//! they can wire the TrUAPI core into a browser or worker shell. +//! +//! The browser side hands a `callbacks` object (a `JsBridge`) to the +//! constructor. The bridge implements every host-side capability the +//! [`truapi_platform::Platform`] trait set requires. Internally the bridge +//! is wrapped in a [`SendWrapper`] so it satisfies the `Send` bound the +//! platform trait set imposes; sound on wasm32 because the runtime is +//! single-threaded. + +use core::cell::Cell; +use core::future::Future; +use core::pin::Pin; +use core::task::{Context, Poll}; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use futures::channel::mpsc; +use futures::stream::{self, BoxStream, Stream, StreamExt}; +use js_sys::{Array, Function, Reflect, Uint8Array}; +use parity_scale_codec::Decode; +use send_wrapper::SendWrapper; +use truapi::v01; +use truapi_platform::{ + ChainProvider, HostInfo, JsonRpcConnection, PairingHostConfig, PlatformInfo, ProductContext, + RuntimeConfigValidationError, +}; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +use crate::subscription::Spawner; +use crate::{ + FrameSink, PairingHostRuntime, PermissionAuthorizationRequest, PermissionAuthorizationStatus, + ProductRuntime, +}; + +mod generated_bridge; + +use generated_bridge::JsBridge; + +/// Per-core JS channel: outgoing frames and teardown for one product core. +struct CoreChannel { + emit_frame: Function, + dispose: Function, +} + +impl CoreChannel { + fn from_js(callbacks: &JsValue) -> Result { + Ok(Self { + emit_frame: get_function(callbacks, "emitFrame")?, + dispose: get_optional_function(callbacks, "dispose")?.unwrap_or_else(noop_function), + }) + } +} + +struct WasmFrameSink { + emit_frame: SendWrapper, +} + +impl FrameSink for WasmFrameSink { + fn emit_frame(&self, frame: Vec) { + let frame = Uint8Array::from(frame.as_slice()); + if let Err(err) = self.emit_frame.call1(&JsValue::NULL, &frame) { + web_sys::console::error_1(&err); + } + } +} + +struct WasmPlatform { + bridge: SendWrapper>, +} + +impl WasmPlatform { + fn new(bridge: Arc) -> Self { + Self { + bridge: SendWrapper::new(bridge), + } + } +} + +#[truapi_platform::async_trait] +impl ChainProvider for WasmPlatform { + async fn connect( + &self, + genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + let chain_connect = self.bridge.chain_connect.clone(); + let chain_connect = SendWrapper::new(chain_connect); + SendWrapper::new(async move { + let (response_tx, response_rx) = mpsc::unbounded::(); + let on_response = Closure::wrap(Box::new(move |json: JsValue| { + // The host must hand back JSON-RPC frames as strings. Drop (and + // log) non-string values rather than forwarding an empty frame + // that would desync request/response correlation. + match json.as_string() { + Some(s) => { + let _ = response_tx.unbounded_send(s); + } + None => web_sys::console::error_1(&JsValue::from_str( + "chainConnect onResponse expected a JSON string; dropping non-string value", + )), + } + }) as Box); + + let genesis_arg = JsValue::from_str(&format!("0x{}", hex::encode(&genesis_hash))); + let returned = chain_connect + .call2( + &JsValue::NULL, + &genesis_arg, + on_response.as_ref().unchecked_ref(), + ) + .map_err(|err| generic(js_to_string(err)))?; + let resolved = await_optional_promise(returned).await.map_err(generic)?; + if resolved.is_null() || resolved.is_undefined() { + return Err(generic("chainConnect returned no connection".into())); + } + let send_fn = Reflect::get(&resolved, &JsValue::from_str("send")) + .map_err(|_| generic("chainConnect must return { send, close }".into()))? + .dyn_into::() + .map_err(|_| generic("chainConnect.send must be a function".into()))?; + let close_fn = Reflect::get(&resolved, &JsValue::from_str("close")) + .map_err(|_| generic("chainConnect.close must be a function".into()))? + .dyn_into::() + .map_err(|_| generic("chainConnect.close must be a function".into()))?; + + Ok(Box::new(JsCallbackJsonRpcConnection { + send_fn: SendWrapper::new(send_fn), + close_fn: SendWrapper::new(close_fn), + closed: AtomicBool::new(false), + _on_response: SendWrapper::new(on_response), + response_rx: std::sync::Mutex::new(Some(response_rx)), + }) as Box) + }) + .await + } +} + +// Account, signing, and statement-store flows live in the Rust core itself. +// The JS bridge only carries callbacks for platform capabilities the core +// cannot satisfy alone; account authority is selected by the runtime. + +struct JsSubscriptionStream { + rx: mpsc::UnboundedReceiver, + _send_item: SendWrapper>, + dispose: Option>, +} + +impl Stream for JsSubscriptionStream { + type Item = T; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.rx).poll_next(cx) + } +} + +impl Drop for JsSubscriptionStream { + fn drop(&mut self) { + if let Some(dispose) = self.dispose.take() { + let _ = dispose.call0(&JsValue::NULL); + } + } +} + +fn invoke_js_subscription( + fn_: &Function, + payload: Option>, + parse_item: fn(JsValue) -> Result, +) -> BoxStream<'static, Result> +where + T: Send + 'static, +{ + let (tx, rx) = mpsc::unbounded::>(); + let send_item = Closure::wrap(Box::new(move |value: JsValue| { + let item = parse_item(value).map_err(generic); + let _ = tx.unbounded_send(item); + }) as Box); + + let call_result = match payload { + Some(payload) => { + let arg = Uint8Array::from(payload.as_slice()); + fn_.call2(&JsValue::NULL, &arg, send_item.as_ref().unchecked_ref()) + } + None => fn_.call1(&JsValue::NULL, send_item.as_ref().unchecked_ref()), + }; + + let dispose = match call_result { + Ok(value) if value.is_null() || value.is_undefined() => None, + Ok(value) => match value.dyn_into::() { + Ok(dispose) => Some(SendWrapper::new(dispose)), + Err(_) => { + return stream::once(async { + Err(generic( + "subscription callback must return a dispose function, null, or undefined" + .to_string(), + )) + }) + .boxed(); + } + }, + Err(err) => return stream::once(async { Err(generic(js_to_string(err))) }).boxed(), + }; + + Box::pin(JsSubscriptionStream { + rx, + _send_item: SendWrapper::new(send_item), + dispose, + }) +} + +struct JsCallbackJsonRpcConnection { + send_fn: SendWrapper, + close_fn: SendWrapper, + closed: AtomicBool, + /// Closure must outlive the connection so JS keeps a live ref to the + /// response sink. Dropped together with the rest of the struct. + _on_response: SendWrapper>, + response_rx: std::sync::Mutex>>, +} + +impl JsonRpcConnection for JsCallbackJsonRpcConnection { + fn send(&self, request: String) { + let arg = JsValue::from_str(&request); + if let Err(err) = self.send_fn.call1(&JsValue::NULL, &arg) { + web_sys::console::error_1(&err); + } + } + + /// Single-take: the response receiver is handed out exactly once. A second + /// call yields an empty stream (and logs), since the channel has one + /// consumer. + fn responses(&self) -> BoxStream<'static, String> { + let mut guard = self.response_rx.lock().unwrap(); + match guard.take() { + Some(rx) => rx.boxed(), + None => { + web_sys::console::error_1(&JsValue::from_str( + "JsCallbackJsonRpcConnection::responses() called more than once", + )); + futures::stream::empty().boxed() + } + } + } + + fn close(&self) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + let _ = self.close_fn.call0(&JsValue::NULL); + } +} + +impl Drop for JsCallbackJsonRpcConnection { + fn drop(&mut self) { + self.close(); + } +} + +fn generic(reason: String) -> v01::GenericError { + v01::GenericError { reason } +} + +/// Await the JS callback's return value if it's a Promise; pass other +/// values through unchanged. Every host callback resolves through this so +/// the JS side is free to be sync or async. +async fn await_optional_promise(returned: JsValue) -> Result { + if returned.is_instance_of::() { + let promise = returned.unchecked_into::(); + wasm_bindgen_futures::JsFuture::from(promise) + .await + .map_err(js_to_string) + } else { + Ok(returned) + } +} + +fn call_js_function(fn_: &Function, args: &[JsValue]) -> Result { + let js_args = Array::new(); + for arg in args { + js_args.push(arg); + } + fn_.apply(&JsValue::NULL, &js_args).map_err(js_to_string) +} + +fn invoke_unit( + fn_: &Function, + args: Vec, +) -> impl Future> + Send { + let fn_ = fn_.clone(); + SendWrapper::new(async move { + let returned = call_js_function(&fn_, &args)?; + await_optional_promise(returned).await.map(|_| ()) + }) +} + +fn invoke_bool( + fn_: &Function, + args: Vec, +) -> impl Future> + Send { + let fn_ = fn_.clone(); + SendWrapper::new(async move { + let returned = call_js_function(&fn_, &args)?; + let resolved = await_optional_promise(returned).await?; + // A non-boolean resolved value is a host contract violation; surface it + // rather than silently masking it as `false` (which would read as a + // denial / unsupported and hide the host bug). + resolved + .as_bool() + .ok_or_else(|| "callback must resolve to a boolean".to_string()) + }) +} + +fn invoke_bytes_return( + fn_: &Function, + args: Vec, +) -> impl Future, String>> + Send { + let fn_ = fn_.clone(); + SendWrapper::new(async move { + let returned = call_js_function(&fn_, &args)?; + let resolved = await_optional_promise(returned).await?; + resolved + .dyn_into::() + .map(|array| array.to_vec()) + .map_err(|_| "callback must resolve to Uint8Array".to_string()) + }) +} + +fn invoke_optional_bytes_return( + fn_: &Function, + args: Vec, + expected: &'static str, +) -> impl Future>, String>> + Send { + let fn_ = fn_.clone(); + SendWrapper::new(async move { + let returned = call_js_function(&fn_, &args)?; + let resolved = await_optional_promise(returned).await?; + if resolved.is_null() || resolved.is_undefined() { + return Ok(None); + } + resolved + .dyn_into::() + .map(|array| Some(array.to_vec())) + .map_err(|_| expected.to_string()) + }) +} + +fn decode_bytes(bytes: Vec, message: &str) -> Result { + T::decode(&mut bytes.as_slice()).map_err(|_| message.to_string()) +} + +fn decode_js_item(value: JsValue, label: &str) -> Result { + let bytes = value + .dyn_into::() + .map_err(|_| format!("{label} item must be Uint8Array"))? + .to_vec(); + decode_bytes(bytes, &format!("encoded {label} item did not decode")) +} + +fn parse_optional_bytes_item(value: JsValue) -> Result>, String> { + if value.is_null() || value.is_undefined() { + return Ok(None); + } + value + .dyn_into::() + .map(|array| Some(array.to_vec())) + .map_err(|_| "optional bytes item must be Uint8Array, null, or undefined".to_string()) +} + +fn js_to_string(value: JsValue) -> String { + value + .as_string() + .or_else(|| { + value + .dyn_ref::() + .map(|err| err.message().into()) + }) + .unwrap_or_else(|| format!("{value:?}")) +} + +fn get_function(callbacks: &JsValue, name: &str) -> Result { + let value = Reflect::get(callbacks, &JsValue::from_str(name))?; + value + .dyn_into::() + .map_err(|_| JsValue::from_str(&format!("callbacks.{name} must be a function"))) +} + +fn get_optional_function(callbacks: &JsValue, name: &str) -> Result, JsValue> { + let value = Reflect::get(callbacks, &JsValue::from_str(name))?; + if value.is_null() || value.is_undefined() { + return Ok(None); + } + value + .dyn_into::() + .map(Some) + .map_err(|_| JsValue::from_str(&format!("callbacks.{name} must be a function"))) +} + +fn noop_function() -> Function { + Function::new_no_args("") +} + +fn runtime_config_from_js(value: &JsValue) -> Result<(PairingHostConfig, ProductContext), JsValue> { + let host_config = pairing_host_config_from_js(value)?; + let product = product_context_from_js(value)?; + Ok((host_config, product)) +} + +fn pairing_host_config_from_js(value: &JsValue) -> Result { + if value.is_null() || value.is_undefined() { + return Err(JsValue::from_str("hostConfig is required")); + } + + let host = get_required_object(value, "host", "runtimeConfig.host")?; + let platform = get_optional_object(value, "platform", "runtimeConfig.platform")?; + let people = get_required_object(value, "people", "runtimeConfig.people")?; + let pairing = get_required_object(value, "pairing", "runtimeConfig.pairing")?; + let bulletin = get_required_object(value, "bulletin", "runtimeConfig.bulletin")?; + + PairingHostConfig::new( + HostInfo { + name: get_required_string_at(&host, "name", "runtimeConfig.host.name")?, + icon: get_optional_string_at(&host, "icon", "runtimeConfig.host.icon")?, + version: get_optional_string_at(&host, "version", "runtimeConfig.host.version")?, + }, + PlatformInfo { + kind: platform + .as_ref() + .map(|p| get_optional_string_at(p, "type", "runtimeConfig.platform.type")) + .transpose()? + .flatten(), + version: platform + .as_ref() + .map(|p| get_optional_string_at(p, "version", "runtimeConfig.platform.version")) + .transpose()? + .flatten(), + }, + get_required_bytes32_at(&people, "genesisHash", "runtimeConfig.people.genesisHash")?, + get_required_bytes32_at( + &bulletin, + "genesisHash", + "runtimeConfig.bulletin.genesisHash", + )?, + get_required_string_at( + &pairing, + "deeplinkScheme", + "runtimeConfig.pairing.deeplinkScheme", + )?, + ) + .map_err(runtime_config_validation_to_js) +} + +fn product_context_from_js(value: &JsValue) -> Result { + if value.is_null() || value.is_undefined() { + return Err(JsValue::from_str("product is required")); + } + ProductContext::new(get_required_string_at( + value, + "productId", + "runtimeConfig.productId", + )?) + .map_err(runtime_config_validation_to_js) +} + +fn runtime_config_field_to_js(field: &str) -> &str { + match field { + "product_id" => "productId", + "host_info.name" => "host.name", + "pairing_deeplink_scheme" => "pairing.deeplinkScheme", + "people_chain_genesis_hash" => "people.genesisHash", + "bulletin_chain_genesis_hash" => "bulletin.genesisHash", + other => other, + } +} + +fn runtime_config_validation_to_js(err: RuntimeConfigValidationError) -> JsValue { + match err { + RuntimeConfigValidationError::EmptyField { field } => JsValue::from_str(&format!( + "runtimeConfig.{} must not be empty", + runtime_config_field_to_js(field) + )), + RuntimeConfigValidationError::InvalidHostIcon { reason } => JsValue::from_str(&format!( + "runtimeConfig.host.icon must be an absolute HTTPS URL: {reason}" + )), + RuntimeConfigValidationError::InsecureHostIcon { scheme } => JsValue::from_str(&format!( + "runtimeConfig.host.icon must use https scheme, got {scheme:?}" + )), + RuntimeConfigValidationError::InvalidDeeplinkScheme { scheme } => JsValue::from_str( + &format!("runtimeConfig.pairing.deeplinkScheme must not include ://, got {scheme:?}"), + ), + RuntimeConfigValidationError::InvalidProductId { product_id } => { + JsValue::from_str(&format!( + "runtimeConfig.productId must be a .dot or localhost product identifier, got {product_id:?}" + )) + } + } +} + +fn get_required_object(value: &JsValue, name: &str, path: &str) -> Result { + let property = Reflect::get(value, &JsValue::from_str(name))?; + if property.is_null() || property.is_undefined() { + return Err(JsValue::from_str(&format!("{path} is required"))); + } + if !property.is_object() { + return Err(JsValue::from_str(&format!("{path} must be an object"))); + } + Ok(property) +} + +fn get_optional_object( + value: &JsValue, + name: &str, + path: &str, +) -> Result, JsValue> { + let property = Reflect::get(value, &JsValue::from_str(name))?; + if property.is_null() || property.is_undefined() { + return Ok(None); + } + if !property.is_object() { + return Err(JsValue::from_str(&format!("{path} must be an object"))); + } + Ok(Some(property)) +} + +fn get_optional_string_at( + value: &JsValue, + name: &str, + path: &str, +) -> Result, JsValue> { + let property = Reflect::get(value, &JsValue::from_str(name))?; + if property.is_null() || property.is_undefined() { + return Ok(None); + } + property + .as_string() + .map(Some) + .ok_or_else(|| JsValue::from_str(&format!("{path} must be a string"))) +} + +fn get_required_string_at(value: &JsValue, name: &str, path: &str) -> Result { + get_optional_string_at(value, name, path)? + .ok_or_else(|| JsValue::from_str(&format!("{path} is required"))) +} + +fn get_optional_bytes32_at( + value: &JsValue, + name: &str, + path: &str, +) -> Result, JsValue> { + let property = Reflect::get(value, &JsValue::from_str(name))?; + if property.is_null() || property.is_undefined() { + return Ok(None); + } + if let Some(hex) = property.as_string() { + return parse_hex32(&hex) + .map(Some) + .map_err(|reason| JsValue::from_str(&format!("{path}: {reason}"))); + } + let array = property + .dyn_into::() + .map_err(|_| JsValue::from_str(&format!("{path} must be hex or Uint8Array")))?; + let bytes = array.to_vec(); + bytes.try_into().map(Some).map_err(|bytes: Vec| { + JsValue::from_str(&format!( + "{path} must be exactly 32 bytes, got {}", + bytes.len() + )) + }) +} + +fn get_required_bytes32_at(value: &JsValue, name: &str, path: &str) -> Result<[u8; 32], JsValue> { + get_optional_bytes32_at(value, name, path)? + .ok_or_else(|| JsValue::from_str(&format!("{path} is required"))) +} + +fn parse_hex32(value: &str) -> Result<[u8; 32], String> { + let raw = value.strip_prefix("0x").unwrap_or(value); + if raw.len() != 64 { + return Err(format!( + "expected 32-byte hex string, got {} hex chars", + raw.len() + )); + } + let bytes = hex::decode(raw).map_err(|_| "invalid hex".to_string())?; + bytes + .try_into() + .map_err(|bytes: Vec| format!("expected 32 bytes, got {}", bytes.len())) +} + +fn decode_permission_authorization_request( + payload: &[u8], +) -> Result { + PermissionAuthorizationRequest::decode(&mut &*payload).map_err(|err| { + JsValue::from_str(&format!( + "permission authorization request did not decode: {err}" + )) + }) +} + +fn decode_permission_authorization_requests( + payloads: &Array, +) -> Result, JsValue> { + let mut requests = Vec::with_capacity(payloads.length() as usize); + for payload in payloads.iter() { + let payload = payload + .dyn_into::() + .map_err(|_| JsValue::from_str("permission authorization request must be bytes"))?; + requests.push(decode_permission_authorization_request(&payload.to_vec())?); + } + Ok(requests) +} + +fn permission_authorization_status_to_js(status: PermissionAuthorizationStatus) -> JsValue { + JsValue::from_str(match status { + PermissionAuthorizationStatus::NotDetermined => "NotDetermined", + PermissionAuthorizationStatus::Denied => "Denied", + PermissionAuthorizationStatus::Authorized => "Authorized", + }) +} + +fn permission_authorization_status_from_js( + status: &str, +) -> Result { + match status { + "NotDetermined" => Ok(PermissionAuthorizationStatus::NotDetermined), + "Denied" => Ok(PermissionAuthorizationStatus::Denied), + "Authorized" => Ok(PermissionAuthorizationStatus::Authorized), + other => Err(JsValue::from_str(&format!( + "unknown permission authorization status: {other}" + ))), + } +} + +fn generic_error_to_js(err: v01::GenericError) -> JsValue { + JsValue::from_str(&err.reason) +} + +struct WasmCoreInner { + core: ProductRuntime, + dispose_fn: SendWrapper, + disposed: Cell, + disposing: Cell, +} + +/// JS-callable handle to a long-lived pairing-host runtime shared by product +/// cores. +#[wasm_bindgen] +pub struct WasmPairingHostRuntime { + runtime: Rc, +} + +#[wasm_bindgen] +impl WasmPairingHostRuntime { + /// Build a shared runtime from host-level platform callbacks and host config. + #[wasm_bindgen(constructor)] + pub fn new( + callbacks: JsValue, + host_config: JsValue, + ) -> Result { + console_error_panic_hook::set_once(); + crate::logging::init(); + let bridge = Arc::new(JsBridge::from_js(&callbacks)?); + let platform = Arc::new(WasmPlatform::new(bridge)); + let spawner: Spawner = Arc::new(|fut| { + wasm_bindgen_futures::spawn_local(fut); + }); + let host_config = pairing_host_config_from_js(&host_config)?; + Ok(Self { + runtime: Rc::new(PairingHostRuntime::new(platform, host_config, spawner)), + }) + } + + /// Build one product-scoped runtime from this pairing host runtime. + #[wasm_bindgen(js_name = productRuntime)] + pub fn product_runtime( + &self, + product: JsValue, + core_callbacks: JsValue, + ) -> Result { + let product = product_context_from_js(&product)?; + let channel = CoreChannel::from_js(&core_callbacks)?; + let sink = Arc::new(WasmFrameSink { + emit_frame: SendWrapper::new(channel.emit_frame), + }); + let runtime = self.runtime.product_runtime(product, sink); + Ok(WasmProductRuntime::from_parts(runtime, channel.dispose)) + } + + /// Disconnect the shared account-authority session. + #[wasm_bindgen(js_name = disconnectSession)] + pub async fn disconnect_session(&self) { + self.runtime.disconnect_session().await; + } + + /// Cancel an in-flight pairing flow. + #[wasm_bindgen(js_name = cancelPairing)] + pub fn cancel_pairing(&self) { + self.runtime.cancel_pairing(); + } + + /// Notify the runtime that the auth session slot may have changed. + #[wasm_bindgen(js_name = notifySessionStoreChanged)] + pub fn notify_session_store_changed(&self) { + self.runtime.notify_session_store_changed(); + } + + /// Read a stored permission authorization status for a product. + #[wasm_bindgen(js_name = permissionAuthorizationStatus)] + pub async fn permission_authorization_status( + &self, + product_id: String, + payload: Vec, + ) -> Result { + let request = decode_permission_authorization_request(&payload)?; + let status = self + .runtime + .permission_authorization_status(&product_id, request) + .await + .map_err(generic_error_to_js)?; + Ok(permission_authorization_status_to_js(status)) + } + + /// Read stored permission authorization statuses for a product. + #[wasm_bindgen(js_name = permissionAuthorizationStatuses)] + pub async fn permission_authorization_statuses( + &self, + product_id: String, + payloads: Array, + ) -> Result { + let requests = decode_permission_authorization_requests(&payloads)?; + let statuses = self + .runtime + .permission_authorization_statuses(&product_id, requests) + .await + .map_err(generic_error_to_js)?; + let values = Array::new(); + for status in statuses { + values.push(&permission_authorization_status_to_js(status)); + } + Ok(values) + } + + /// Update a stored permission authorization status for a product. + #[wasm_bindgen(js_name = setPermissionAuthorizationStatus)] + pub async fn set_permission_authorization_status( + &self, + product_id: String, + payload: Vec, + status: String, + ) -> Result<(), JsValue> { + let request = decode_permission_authorization_request(&payload)?; + let status = permission_authorization_status_from_js(&status)?; + self.runtime + .set_permission_authorization_status(&product_id, request, status) + .await + .map_err(generic_error_to_js) + } +} + +/// Set the live log level (`off`/`error`/`warn`/`info`/`debug`/`trace`). +/// Hosts may call this during boot, or again at any time to re-tune verbosity. +/// Unknown values are parsed as `off`. +#[wasm_bindgen(js_name = setLogLevel)] +pub fn set_log_level(level: &str) { + crate::logging::set_level_from_str(level); +} + +/// JS-callable handle to one product-scoped TrUAPI core. +#[wasm_bindgen] +pub struct WasmProductRuntime { + inner: Rc, +} + +impl WasmProductRuntime { + fn from_parts(core: ProductRuntime, dispose_fn: Function) -> Self { + Self { + inner: Rc::new(WasmCoreInner { + core, + dispose_fn: SendWrapper::new(dispose_fn), + disposed: Cell::new(false), + disposing: Cell::new(false), + }), + } + } +} + +#[wasm_bindgen] +impl WasmProductRuntime { + /// Build the core from a JS callbacks object. The object must define + /// every host capability the [`truapi_platform::Platform`] trait set + /// requires (camelCase property names; see the source for the full + /// list). + #[wasm_bindgen(constructor)] + pub fn new(callbacks: JsValue, runtime_config: JsValue) -> Result { + // Surface Rust panics to the browser console. A panic mid-dispatch + // aborts the call as a wasm trap; the host should treat a thrown error + // from `receiveFrame` as a fatal-instance signal and rebuild the + // core rather than continue using it. + console_error_panic_hook::set_once(); + crate::logging::init(); + let bridge = Arc::new(JsBridge::from_js(&callbacks)?); + let channel = CoreChannel::from_js(&callbacks)?; + let frame_sink = Arc::new(WasmFrameSink { + emit_frame: SendWrapper::new(channel.emit_frame), + }); + let platform = Arc::new(WasmPlatform::new(bridge)); + let spawner: Spawner = Arc::new(|fut| { + wasm_bindgen_futures::spawn_local(fut); + }); + let (host_config, product) = runtime_config_from_js(&runtime_config)?; + let core = ProductRuntime::from_platform_with_config( + platform, + host_config, + product, + spawner, + frame_sink, + ); + Ok(Self::from_parts(core, channel.dispose)) + } + + /// Push a SCALE-encoded protocol frame into the dispatcher. Responses + /// (and subscription items) flow back through the `emitFrame` + /// callback. + #[wasm_bindgen(js_name = receiveFrame)] + pub async fn receive_frame(&self, frame: Vec) -> Result<(), JsValue> { + self.inner + .core + .receive_frame(frame) + .await + .map_err(|err| JsValue::from_str(&err.to_string())) + } + + /// Read a stored permission authorization status without prompting. + /// + /// `payload` is a SCALE-encoded `PermissionAuthorizationRequest`. + #[wasm_bindgen(js_name = permissionAuthorizationStatus)] + pub async fn permission_authorization_status( + &self, + payload: Vec, + ) -> Result { + let request = decode_permission_authorization_request(&payload)?; + let status = self + .inner + .core + .permission_authorization_status(request) + .await + .map_err(generic_error_to_js)?; + Ok(permission_authorization_status_to_js(status)) + } + + /// Read stored permission authorization statuses without prompting. + /// + /// `payloads` is an array of SCALE-encoded + /// `PermissionAuthorizationRequest` values. Results follow the same order. + #[wasm_bindgen(js_name = permissionAuthorizationStatuses)] + pub async fn permission_authorization_statuses( + &self, + payloads: Array, + ) -> Result { + let requests = decode_permission_authorization_requests(&payloads)?; + let statuses = self + .inner + .core + .permission_authorization_statuses(requests) + .await + .map_err(generic_error_to_js)?; + let values = Array::new(); + for status in statuses { + values.push(&permission_authorization_status_to_js(status)); + } + Ok(values) + } + + /// Update a stored permission authorization status. Passing + /// `"NotDetermined"` clears the stored value so the next product request + /// prompts again. + #[wasm_bindgen(js_name = setPermissionAuthorizationStatus)] + pub async fn set_permission_authorization_status( + &self, + payload: Vec, + status: String, + ) -> Result<(), JsValue> { + let request = decode_permission_authorization_request(&payload)?; + let status = permission_authorization_status_from_js(&status)?; + self.inner + .core + .set_permission_authorization_status(request, status) + .await + .map_err(generic_error_to_js) + } + + /// Tear down the bridge. Invokes the JS-side `dispose` callback so the + /// host can drop its end of the wiring. + pub fn dispose(&self) -> Result<(), JsValue> { + if self.inner.disposed.get() { + return Ok(()); + } + if self.inner.disposing.replace(true) { + return Ok(()); + } + + self.inner.core.dispose(); + + let result = self.inner.dispose_fn.call0(&JsValue::NULL).map(|_| ()); + + self.inner.disposed.set(true); + self.inner.disposing.set(false); + result + } + + /// Core-owned logout/disconnect. Best-effort notifies the SSO peer when + /// the session has channel material, then clears in-memory and persisted + /// session state. + #[wasm_bindgen(js_name = disconnectSession)] + pub async fn disconnect_session(&self) -> Result<(), JsValue> { + self.inner.core.disconnect_session().await; + Ok(()) + } +} diff --git a/rust/crates/truapi-server/src/wasm/generated_bridge.rs b/rust/crates/truapi-server/src/wasm/generated_bridge.rs new file mode 100644 index 00000000..c08a4868 --- /dev/null +++ b/rust/crates/truapi-server/src/wasm/generated_bridge.rs @@ -0,0 +1,288 @@ +//! Auto-generated by truapi-codegen. Do not edit. +//! +//! Mechanical wasm-bindgen callback bridge derived from +//! `truapi-platform`. Raw callback names and payload shapes match the +//! generated TypeScript host-callback adapter. + +use futures::stream::BoxStream; +use js_sys::{Function, Uint8Array}; +use parity_scale_codec::Encode; +use truapi::v01; +use wasm_bindgen::JsValue; + +use super::{ + WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, + 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 +/// Rust default bodies are still required here because the generated TS +/// adapter resolves optional host callbacks before constructing this +/// raw callback object. +pub(super) struct JsBridge { + pub(super) auth_state_changed: Function, + pub(super) chain_connect: Function, + pub(super) read_core_storage: Function, + pub(super) write_core_storage: Function, + pub(super) clear_core_storage: Function, + pub(super) feature_supported: Function, + pub(super) navigate_to: Function, + pub(super) push_notification: Function, + pub(super) cancel_notification: Function, + pub(super) device_permission: Function, + pub(super) remote_permission: Function, + pub(super) lookup_preimage: Function, + pub(super) read: Function, + pub(super) write: Function, + pub(super) clear: Function, + pub(super) subscribe_theme: Function, + pub(super) confirm_user_action: Function, +} + +impl JsBridge { + pub(super) fn from_js(callbacks: &JsValue) -> Result { + Ok(Self { + auth_state_changed: get_function(callbacks, "authStateChanged")?, + chain_connect: get_function(callbacks, "chainConnect")?, + read_core_storage: get_function(callbacks, "readCoreStorage")?, + write_core_storage: get_function(callbacks, "writeCoreStorage")?, + clear_core_storage: get_function(callbacks, "clearCoreStorage")?, + feature_supported: get_function(callbacks, "featureSupported")?, + navigate_to: get_function(callbacks, "navigateTo")?, + push_notification: get_function(callbacks, "pushNotification")?, + cancel_notification: get_function(callbacks, "cancelNotification")?, + device_permission: get_function(callbacks, "devicePermission")?, + remote_permission: get_function(callbacks, "remotePermission")?, + lookup_preimage: get_function(callbacks, "lookupPreimage")?, + read: get_function(callbacks, "read")?, + write: get_function(callbacks, "write")?, + clear: get_function(callbacks, "clear")?, + subscribe_theme: get_function(callbacks, "subscribeTheme")?, + confirm_user_action: get_function(callbacks, "confirmUserAction")?, + }) + } +} + +impl truapi_platform::AuthPresenter for WasmPlatform { + fn auth_state_changed(&self, state: truapi_platform::AuthState) { + if let Err(reason) = call_js_function( + &self.bridge.auth_state_changed, + &vec![Uint8Array::from(state.encode().as_slice()).into()], + ) { + web_sys::console::error_1(&JsValue::from_str(&reason)); + } + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::CoreStorage for WasmPlatform { + async fn read_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result>, v01::GenericError> { + invoke_optional_bytes_return( + &self.bridge.read_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + "readCoreStorage must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(generic) + } + + async fn write_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.write_core_storage, + vec![ + Uint8Array::from(key.encode().as_slice()).into(), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(generic) + } + + async fn clear_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.clear_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Features for WasmPlatform { + async fn feature_supported( + &self, + request: v01::HostFeatureSupportedRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.feature_supported, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "featureSupported response did not decode", + ) + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Navigation for WasmPlatform { + async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { + invoke_unit(&self.bridge.navigate_to, vec![JsValue::from_str(&url)]) + .await + .map_err(|reason| v01::HostNavigateToError::Unknown { reason }) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Notifications for WasmPlatform { + async fn push_notification( + &self, + notification: v01::HostPushNotificationRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.push_notification, + vec![Uint8Array::from(notification.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "pushNotification response did not decode", + ) + .map_err(generic) + } + + async fn cancel_notification(&self, id: v01::NotificationId) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.cancel_notification, + vec![JsValue::from_f64(f64::from(id))], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Permissions for WasmPlatform { + async fn device_permission( + &self, + request: v01::HostDevicePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.device_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "devicePermission response did not decode", + ) + .map_err(generic) + } + + async fn remote_permission( + &self, + request: v01::RemotePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.remote_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "remotePermission response did not decode", + ) + .map_err(generic) + } +} + +impl truapi_platform::PreimageHost for WasmPlatform { + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + invoke_js_subscription( + &self.bridge.lookup_preimage, + Some(key), + parse_optional_bytes_item, + ) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::ProductStorage for WasmPlatform { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { + invoke_optional_bytes_return( + &self.bridge.read, + vec![JsValue::from_str(&key)], + "read must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit( + &self.bridge.write, + vec![ + JsValue::from_str(&key), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit(&self.bridge.clear, vec![JsValue::from_str(&key)]) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } +} + +impl truapi_platform::ThemeHost for WasmPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + invoke_js_subscription(&self.bridge.subscribe_theme, None, parse_theme_variant_item) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::UserConfirmation for WasmPlatform { + async fn confirm_user_action( + &self, + review: truapi_platform::UserConfirmationReview, + ) -> Result { + invoke_bool( + &self.bridge.confirm_user_action, + vec![Uint8Array::from(review.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +fn parse_theme_variant_item(value: JsValue) -> Result { + decode_js_item::(value, "ThemeVariant") +} diff --git a/rust/crates/truapi-server/tests/common/mod.rs b/rust/crates/truapi-server/tests/common/mod.rs index 5819f53c..33a5b8cb 100644 --- a/rust/crates/truapi-server/tests/common/mod.rs +++ b/rust/crates/truapi-server/tests/common/mod.rs @@ -6,9 +6,9 @@ use std::sync::Mutex; use futures::stream::{self, BoxStream}; use truapi::v01; use truapi_platform::{ - AuthPresenter, BulletinAllowanceSigner, ChainProvider, CoreStorage, CoreStorageKey, Features, - HostInfo, JsonRpcConnection, Navigation, Notifications, PairingHostConfig, Permissions, - PlatformInfo, PreimageHost, ProductContext, ProductStorage, ThemeHost, UserConfirmation, + AuthPresenter, ChainProvider, CoreStorage, CoreStorageKey, Features, HostInfo, + JsonRpcConnection, Navigation, Notifications, PairingHostConfig, Permissions, PlatformInfo, + PreimageHost, ProductContext, ProductStorage, ThemeHost, UserConfirmation, UserConfirmationReview, }; use truapi_server::frame::ProtocolMessage; @@ -57,6 +57,7 @@ pub fn test_runtime_config() -> (PairingHostConfig, ProductContext) { }, PlatformInfo::default(), [0xa2; 32], + [0xbb; 32], "polkadotapp".to_string(), ) .expect("test host runtime config is valid"), @@ -188,15 +189,7 @@ impl ThemeHost for WireShapePlatform { } } -#[truapi_platform::async_trait] impl PreimageHost for WireShapePlatform { - async fn submit_preimage( - &self, - value: Vec, - _bulletin_allowance_signer: BulletinAllowanceSigner, - ) -> Result, v01::PreimageSubmitError> { - Ok(value) - } fn lookup_preimage( &self, _key: Vec, diff --git a/rust/crates/truapi-server/tests/fixtures/bulletin_paseo_metadata.scale b/rust/crates/truapi-server/tests/fixtures/bulletin_paseo_metadata.scale new file mode 100644 index 00000000..670b2431 Binary files /dev/null and b/rust/crates/truapi-server/tests/fixtures/bulletin_paseo_metadata.scale differ diff --git a/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata.scale b/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata.scale new file mode 100644 index 00000000..28a7ac3f Binary files /dev/null and b/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata.scale differ diff --git a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs index 80f42931..b98bdbb1 100644 --- a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs +++ b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs @@ -60,6 +60,7 @@ fn runtime_config() -> PairingHostConfig { version: Some("192.32".to_string()), }, [0xa2; 32], + [0xbb; 32], "polkadotapp".to_string(), ) .expect("test runtime config is valid") diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index 83211328..1ccaac15 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -43,6 +43,15 @@ pub trait Account: Send + Sync { /// }); /// assert(result.isOk(), "getAccount failed:", result); /// console.log("account retrieved:", result.value); + /// + /// const otherProduct = await truapi.account.getAccount({ + /// productAccountId: { + /// dotNsIdentifier: "other-product.dot", + /// derivationIndex: 0, + /// }, + /// }); + /// assert(otherProduct.isOk(), "cross-product getAccount was denied or failed:", otherProduct); + /// console.log("other product account retrieved after approval:", otherProduct.value); /// ``` #[wire(request_id = 22)] async fn get_account( diff --git a/rust/crates/truapi/src/api/chain.rs b/rust/crates/truapi/src/api/chain.rs index 158dd12f..d2d73fa2 100644 --- a/rust/crates/truapi/src/api/chain.rs +++ b/rust/crates/truapi/src/api/chain.rs @@ -362,9 +362,17 @@ pub trait Chain: Send + Sync { /// ```ts /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; /// + /// // Start a broadcast, then stop it using the returned operation id. + /// const broadcast = await truapi.chain.broadcastTransaction({ + /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// transaction: "0x", + /// }); + /// assert(broadcast.isOk(), "broadcastTransaction failed:", broadcast); + /// assert(broadcast.value.operationId, "broadcastTransaction returned no operation id"); + /// /// const result = await truapi.chain.stopTransaction({ /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, - /// operationId: "op-id", + /// operationId: broadcast.value.operationId, /// }); /// assert(result.isOk(), "stopTransaction failed:", result); /// console.log("transaction broadcast stopped"); diff --git a/rust/crates/truapi/src/api/preimage.rs b/rust/crates/truapi/src/api/preimage.rs index 455d54df..5e818de3 100644 --- a/rust/crates/truapi/src/api/preimage.rs +++ b/rust/crates/truapi/src/api/preimage.rs @@ -15,7 +15,8 @@ pub trait Preimage: Send + Sync { /// import { firstValueFrom, from } from "rxjs"; /// /// // Submit a preimage first so the lookup resolves to a value. - /// const submitted = await truapi.preimage.submit("0xdeadbeef"); + /// const value = crypto.getRandomValues(new Uint8Array(4)).toHex() as `0x${string}`; + /// const submitted = await truapi.preimage.submit(value); /// assert(submitted.isOk(), "submit failed:", submitted); /// /// const item = await firstValueFrom( @@ -35,7 +36,8 @@ pub trait Preimage: Send + Sync { /// Submit a preimage. Returns the preimage key (hash) on success. /// /// ```ts - /// const result = await truapi.preimage.submit("0xdeadbeef"); + /// const value = crypto.getRandomValues(new Uint8Array(4)).toHex() as `0x${string}`; + /// const result = await truapi.preimage.submit(value); /// assert(result.isOk(), "submit failed:", result); /// console.log("preimage submitted:", result.value); /// ``` diff --git a/rust/crates/truapi/src/api/statement_store.rs b/rust/crates/truapi/src/api/statement_store.rs index 16cfc8cd..8a3a5f2b 100644 --- a/rust/crates/truapi/src/api/statement_store.rs +++ b/rust/crates/truapi/src/api/statement_store.rs @@ -25,14 +25,8 @@ pub trait StatementStore: Send + Sync { /// const expiry = BigInt(Math.floor(Date.now() / 1000) + 86400) << 32n; /// const statement: Statement = { expiry, topics: [topic] }; /// - /// const proofResult = await truapi.statementStore.createProof({ - /// productAccountId: { - /// dotNsIdentifier: "truapi-playground.dot", - /// derivationIndex: 0, - /// }, - /// statement, - /// }); - /// assert(proofResult.isOk(), "createProof failed:", proofResult); + /// const proofResult = await truapi.statementStore.createProofAuthorized(statement); + /// assert(proofResult.isOk(), "createProofAuthorized failed:", proofResult); /// /// statement.proof = proofResult.value.proof; /// console.log("submitting statement:", statement); @@ -68,7 +62,8 @@ pub trait StatementStore: Send + Sync { /// /// **Deprecated:** use [`create_proof_authorized`](Self::create_proof_authorized) /// instead, which uses a pre-allocated allowance account and does not - /// require a per-call signing prompt. + /// require a per-call signing prompt. Pairing hosts may reject this method + /// when their signing channel cannot sign statement proof payloads exactly. /// /// ```ts /// // Expiry packs a Unix-seconds timestamp in the high 32 bits; a day out @@ -84,8 +79,11 @@ pub trait StatementStore: Send + Sync { /// }, /// statement, /// }); - /// assert(result.isOk(), "createProof failed:", result); - /// console.log("proof created:", result.value); + /// if (result.isErr()) { + /// console.log("deprecated createProof unavailable:", result.error); + /// } else { + /// console.log("proof created:", result.value); + /// } /// ``` #[wire(request_id = 60)] async fn create_proof( @@ -136,14 +134,8 @@ pub trait StatementStore: Send + Sync { /// const expiry = BigInt(Math.floor(Date.now() / 1000) + 86400) << 32n; /// const statement = { expiry, topics: [topic] }; /// - /// const proofResult = await truapi.statementStore.createProof({ - /// productAccountId: { - /// dotNsIdentifier: "truapi-playground.dot", - /// derivationIndex: 0, - /// }, - /// statement, - /// }); - /// assert(proofResult.isOk(), "createProof failed:", proofResult); + /// const proofResult = await truapi.statementStore.createProofAuthorized(statement); + /// assert(proofResult.isOk(), "createProofAuthorized failed:", proofResult); /// /// const result = await truapi.statementStore.submit({ /// proof: proofResult.value.proof, diff --git a/scripts/codegen.sh b/scripts/codegen.sh index 86c17f78..7339965b 100755 --- a/scripts/codegen.sh +++ b/scripts/codegen.sh @@ -9,8 +9,9 @@ # --client-examples-output playground/test/generated/examples # --rust-output rust/crates/truapi-server/src/generated # --platform-input target/doc/truapi_platform.json -# --platform-ts-output js/packages/truapi-host-wasm/src/generated -# --platform-wasm-adapter-output js/packages/truapi-host-wasm/src/generated +# --platform-ts-output js/packages/truapi-host/src/generated +# --platform-wasm-adapter-output js/packages/truapi-host/src/generated +# --platform-rust-output rust/crates/truapi-server/src/wasm # --codec-version 1 # # The client surface defaults to the latest wire version any versioned @@ -32,11 +33,17 @@ cargo run -p truapi-codegen -- \ --client-examples-output playground/test/generated/examples \ --rust-output rust/crates/truapi-server/src/generated \ --platform-input target/doc/truapi_platform.json \ - --platform-ts-output js/packages/truapi-host-wasm/src/generated \ - --platform-wasm-adapter-output js/packages/truapi-host-wasm/src/generated \ + --platform-ts-output js/packages/truapi-host/src/generated \ + --platform-wasm-adapter-output js/packages/truapi-host/src/generated \ + --platform-rust-output rust/crates/truapi-server/src/wasm \ --explorer-output js/packages/truapi/src/explorer \ --codec-version 1 +rustfmt +nightly --edition 2024 \ + rust/crates/truapi-server/src/generated/dispatcher.rs \ + rust/crates/truapi-server/src/generated/wire_table.rs \ + rust/crates/truapi-server/src/wasm/generated_bridge.rs + node scripts/regen-explorer-versions.mjs npm exec --yes -- prettier --write \ @@ -44,7 +51,7 @@ npm exec --yes -- prettier --write \ "js/packages/truapi/src/playground/**/*.ts" \ "js/packages/truapi/src/explorer/**/*.ts" \ "playground/test/generated/examples/**/*.ts" \ - "js/packages/truapi-host-wasm/src/generated/**/*.ts" + "js/packages/truapi-host/src/generated/**/*.ts" # Rebuild dist/ so downstream consumers (in particular the playground, # which picks up @parity/truapi via yarn 1.x file: snapshot) see the @@ -69,4 +76,5 @@ echo "Generated client at js/packages/truapi/src/generated/" echo "Generated playground metadata at js/packages/truapi/src/playground/codegen/" echo "Generated client examples at playground/test/generated/examples/" echo "Generated Rust dispatcher at rust/crates/truapi-server/src/generated/" -echo "Generated host-callbacks WASM adapter at js/packages/truapi-host-wasm/src/generated/" +echo "Generated host-callbacks WASM adapter at js/packages/truapi-host/src/generated/" +echo "Generated Rust WASM bridge at rust/crates/truapi-server/src/wasm/generated_bridge.rs"