Skip to content

Add mobile host scaffolding#215

Open
pgherveou wants to merge 274 commits into
worktree-issue-96-rust-core-portfrom
worktree-issue-96-mobile-followup
Open

Add mobile host scaffolding#215
pgherveou wants to merge 274 commits into
worktree-issue-96-rust-core-portfrom
worktree-issue-96-mobile-followup

Conversation

@pgherveou

Copy link
Copy Markdown
Collaborator

Summary

  • add Android and iOS host package scaffolding for the Rust core
  • add UniFFI/native bridge and localhost WebSocket bridge surfaces
  • add Gradle, SwiftPM, JitPack, and host package CI wiring

Verification

  • cargo check -p uniffi-bindgen-cli
  • cargo check -p truapi-server --features ws-bridge
  • cargo test -p truapi-server --features ws-bridge

pgherveou and others added 30 commits May 30, 2026 07:08
Harden the Rust core runtime and host SDKs against the review of the core
port. No change to the wire protocol or the generated artifacts (codegen
regenerates byte-identically; the committed WASM bundle is rebuilt).

truapi-server:
- Subscriptions use generation-stamped reservation slots: a reused or raced
  request_id stops-and-replaces instead of leaking an unstoppable stream, and
  a _stop arriving before activation cancels the pending subscription.
  Unregistered methods answer Unsupported instead of leaving the caller to
  hang. Dead negotiated_version slot and interrupt API removed.
- chainHead-v1 runtime: single-flight follow setup (no duplicate or leaked
  remote subscriptions), the close-vs-insert race in request_value is closed
  so a caller can no longer hang forever, the pending follow-event buffer is
  bounded, and an orphaned remote follow is unfollowed when its local follow
  is torn down mid-setup.
- WS bridge: constant-time token comparison, bounded inbound message size,
  bounded outbound queue and connection count, documented loopback trust
  model.
- wasm surface registers a panic hook and surfaces non-boolean / non-string
  host returns as errors; permissions persist only genuine user decisions, so
  a transient prompt-callback error no longer locks a capability out; dotns
  classifies trailing-dot FQDNs and lowercases/de-dupes remote domains;
  navigate_to returns an error instead of panicking on a contract violation.
- smoldot provider is wired into the runtime behind its feature (with a
  platform fallback), with finite json-rpc ceilings, a distinguishable
  reconnect error, and Apache-2.0 attribution (SPDX headers +
  THIRD_PARTY_NOTICES.md).
- Tests: subscription-race regressions, malformed-frame drop, subscription
  lifecycle through the wire boundary, and a hardened Rust<->TS wire-table
  parity check (panics on unparseable ids, compares all four subscription
  ids, asserts a row-count floor).

codegen: Rust dispatcher/wire-table emitters use writedoc!/formatdoc!
(byte-identical output); TS host-callbacks handles bare trait-object returns.

JS hosts: worker-runtime faults now reach close subscribers; the WebSocket
provider is built on the shared close-once base provider; the electron
provider detaches port listeners on remote close; new tests for the node
WASM round-trip, WS error/non-binary/CLOSING paths, and the iframe
handshake/origin/dispose paths.

native: Android and iOS HostBridge document that callbacks fire on the
truapi-ws-bridge worker thread; the iOS bootstrap uses a JSONEncoder-based JS
string-literal escaper; Maven coordinates reconciled across build.gradle.kts,
jitpack.yml, and the README.

CI/docs: host-packages CI runs the host-shared smoke test against the
freshly built WASM bundle and triggers on truapi-platform changes; the
Makefile wasm target no longer hides the committed bundle; design docs and
the truapi-platform README are reconciled to the shipped trait set.
Native hosts only use the localhost WebSocket bridge, where the core writes
outbound frames straight to the socket, so the direct
receiveFromProduct/onCoreResponse delivery path was dead weight on the UniFFI
surface.

- truapi-server: remove HostCallbacks::on_core_response, NativeCallbackTransport,
  and NativeTrUApiCore::{receive_from_product, debug_smoke_feature_request_frame}.
- Regenerate the UniFFI Kotlin + Swift bindings to drop those symbols.
- Android + iOS shells: drop HostBridge.onCoreResponse and the
  receiveFromProduct / debugSmokeFeatureRequestFrame wrappers; trim the README
  examples.

Web (WasmTrUApiCore.emitFrame / receiveFromProduct) is unchanged — it is the
primary delivery path there — as is the in-process
TrUApiCore::receive_from_product convenience used by the tests.
Merge @parity/truapi-host-{shared,web,electron} into one package,
@parity/truapi-host-wasm, with tree-shakeable subpath entries:
  .               core: createWasmProvider, createNodeWasmProvider, createHostServer, types
  ./web           createIframeHost, createWebWorkerProvider
  ./electron      createElectronProvider
  ./node-runtime · ./worker-runtime · ./wasm/{web,node}

@parity/truapi-host (no shared core) is unchanged. The ./web and ./electron
entries are side-effect-free and statically pull no core/node-runtime/wasm at
the value level, so bundlers tree-shake them; the `sideEffects` field scopes
the genuinely-effectful worker entry and the wasm glue. The wasm subpath
exports carry a `types` condition for typed consumers.

Updates codegen.sh (--platform-ts-output + prettier + echoes), the Makefile,
host-packages.yml (path filters + jobs), the committed WASM bundle location,
README/CLAUDE.md, the design docs, and package-lock.json to the new package.
@pgherveou pgherveou force-pushed the worktree-issue-96-rust-core-port branch 2 times, most recently from 590abd1 to 4c04a81 Compare June 30, 2026 21:34
pgherveou added 27 commits July 1, 2026 19:15
Add a `truapi-host` binary with three roles that replace the external
signing-bot for local end-to-end testing:

- `relay`: an in-memory statement-store the two hosts pair over.
- `pairing-host`: a seedless host (PairingHostRuntime) that presents a
  pairing deeplink and bridges product byte-frames over WebSocket.
- `signing-host`: a wallet-local host (SigningHostRuntime) that answers the
  handshake and auto-signs, driven by the new SSO responder.

truapi-server gains the §B responder half and the signing-host operations it
needs: encode-side handshake helpers and responder codecs in
`host_logic/sso/`, v4 extrinsic assembly (`host_logic/transaction.rs`),
`runtime/signing_host/sso_responder.rs`, and `sign_payload` /
`create_transaction` on the signing host. Product accounts and
`rootUserAccountId` derive from `//wallet` (host-spec C.0/C.5) via a new
sr25519 hard-junction helper. Bandersnatch ring-VRF product-account aliases
(`get_account_alias`) use the `verifiable` crate, and lite-username
attestation (`host_logic/attestation.rs` + CLI) registers a People-chain
username so `get_user_id` resolves; the pairing host can resolve usernames
from the real People chain while SSO stays on the relay
(`identity_chain_genesis_hash`).

The bun e2e driver (`e2e/`) runs the playground's own generated example
sources against the pairing host and writes `explorer/diagnosis-reports/
headless.md`. With `E2E_LIVE_CHAIN=1` and a registered signer account it
matches the browser host on every method it passes (43/44; the lone failure
is a chain example that sends a deliberately-invalid operation id).
The stop_transaction playground example used a hardcoded placeholder
operationId; it now broadcasts a transaction and stops it with the returned
id, the correct usage. Also emit captured example output for passing methods
in the headless diagnosis report and load the e2e signer mnemonic from a
gitignored e2e/.env.

Note: against the public paseo Asset Hub RPC, transaction_v1_stop rejects
broadcast operation ids (-32602), so this method stays red there; it passes
on hosts whose transaction API honors stop (e.g. smoldot in the browser host).
The headless hosts now connect to the statement store the same way an
iOS/web client does: the real paseo-next-v2 People chain, which serves
both the statement-store RPC and the chain RPC over one WebSocket. The
in-memory relay (relay.rs, the `relay` subcommand, and the relay
orchestration in the e2e runner) is removed; both hosts default
`--statement-store` to the People node.

Statement-store allowance is obtained on-chain. New native module
`src/alloc/` ports `Resources.set_statement_store_account` (pallet 63
call 10): metadata-driven signed-extension encoding + inherited-implication
proof message (pinned to the live-verified known answer), bandersnatch
ring-VRF membership proof via the `verifiable` prover, the unsigned
General (v5) extrinsic, `Members` ring fetch, SSS_SLOT slot scan, a
minimal WS JSON-RPC client, and `author_submitAndWatchExtrinsic`. Before
pairing, the signing host registers allowance for both its own
`//wallet//sso` account and the pairing host's device key, proving its
LitePeople ring membership. A new `alloc-check` subcommand diagnoses (or
`--submit`s) allowance.

statement_store_rpc::submit now inspects the SubmitResult: only `new`/
`known` count as accepted, so `NoAllowance`/`BadProof` rejections surface
instead of being silently dropped.
…ations

The pairing host is now driven by a product script: `pairing-host --script
foo.ts --product-id p.dot [--auto-accept]` starts the frame server and runs the
script with a global `truapi` injected (the `@parity/truapi` client, scoped to
the product) plus a `host` helper. The Rust CLI spawns the script via an
embedded `js/runner.ts`, and the command exits with the script's status, so the
host command is the test — no separate bun orchestrator.

Both hosts take `--auto-accept`; without it every confirmation a web/iOS host
would show as a modal is prompted y/n on the CLI (ApprovalPolicy::Prompt).

`make headless` builds the CLI + client; `e2e/run.sh` is a thin launcher that
runs a product script through a pairing host and pipes the emitted deeplink to a
signing host. Product scripts live in `js/scripts/`: `battery.ts` (curated
signer gate, 7/7) and `diagnosis.ts` (playground generated examples ->
explorer/diagnosis-reports/headless.md, 43/1/20 with a live chain). The former
`e2e/{run-e2e,driver,diagnosis,ws-provider}.ts` and `wait-and-sign.sh` are gone.

Product ids must be a `.dot`/`localhost` identifier; the default is
`headless-playground.dot`.
Product scripts are now top-level code (no `export default` wrapper); the runner
injects `truapi` and a minimal `host` (`productId` + `productAccount(i)` only,
so accounts stay in sync with `--product-id`). Scripts use `console.log`/`throw`
for everything else. `js/scripts/{battery,diagnosis}.ts` converted accordingly.

The signing host reads its wallet mnemonic from `--mnemonic`, else the
`HOST_CLI_SIGNER_MNEMONIC` env var (or a gitignored `e2e/.env`), else the dev
mnemonic.

Allowance registration now waits for the `set_statement_store_account` extrinsic
to finalize, not just reach a block: the statement store does not honor a freshly
-set allowance at `inBlock`, so the handshake submit raced it and failed with
`NoAllowance`. Verified end-to-end against paseo-next-v2 (7/7 signer battery).
Move the entire Bulletin TransactionStorage.store submission into
truapi-server. The core builds the extrinsic offline (subxt 0.50.2), signs
it with the wallet-delegated allowance key, dry-runs it, broadcasts, and
watches for inclusion over the existing chainHead runtime — replacing the
host signer-callback seam so the allowance secret never crosses the
host/FFI boundary.

Core:
- host_logic/extrinsic.rs: offline SubstrateConfig assembler with a
  config-pinned genesis hash, an sr25519 Signer, and metadata / transaction
  validity / events / header decoders.
- host_logic/bulletin.rs: store{data} construction signed with the allowance
  key, with audited pallet/call-index pinning plus a canonical-bytes guard so
  provider metadata cannot redirect the signature, and a memcpy call-data
  encoder that avoids scale-encode's per-byte cost.
- runtime/bulletin_rpc.rs: serialized submit flow (ephemeral with_runtime
  follow, metadata, nonce, validate_transaction dry-run, broadcast, single
  event-loop inclusion watch gated on nonce advance, System.Events dispatch
  check), typed error taxonomy, and broadcast stop on every exit.
- runtime.rs: Preimage::submit gates on bulletin availability before any
  prompt and refreshes the allowance (Increase policy) with one retry on an
  allowance rejection; lookup_subscribe verifies blake2_256(value)==key and
  serves an in-core content-addressed cache.
- BulletinAllowanceKey is zeroized on drop; PreimageHost keeps only
  lookup_preimage; both host configs gain an optional Bulletin genesis hash.

Codegen/TS: regenerate goldens (product wire unchanged) and drop the signer
bridge from the handwritten host worker. CI compiles the crate for
wasm32-unknown-unknown; .gitignore ignores the renamed wasm bundle path.

Bumps the hosts/dotli gitlink to the matching submodule commit.
Adversarial review of the watch loop found two defects:

- A crafted or buggy chain provider could send a self-referential or cyclic
  `NewBlock` parent link. The nonce-advance ancestor walk had no visited-set
  guard and no `.await`, so such a link spun forever, freezing the worker and
  permanently holding the submit lock across all products. The walk is
  extracted into `ancestors_to_check`, which guards against self-parent and
  cyclic links with a visited set, and is unit-tested.
- Blocks that failed the nonce gate were marked checked but never unpinned,
  leaking chainHead pins over the watch's lifetime and risking a false
  BroadcastUnverified once the server's pin limit was hit. They are now
  unpinned like body-negative blocks.

Also log a warning when a host lookup value is downgraded to a miss for
failing the blake2_256(value)==key integrity check.
…bler

The signing-host role now builds and signs transactions locally instead of
returning Unavailable, reusing host_logic/extrinsic.rs.

Because ProductAccountTxPayload carries each extension's `extra` and
`additional_signed` already SCALE-encoded in canonical order, assembly is a
pure offline concatenation — no metadata, no RPC:

- extrinsic.rs gains build_signed_extrinsic_v4 + v4_signer_payload and an
  Sr25519Signer::from_keypair constructor. Body = Compact(len) ++ 0x84 ++
  MultiAddress::Id(signer) ++ MultiSignature::Sr25519(sig) ++ Σextra ++
  call_data; signer payload = call_data ++ Σextra ++ Σadditional_signed,
  blake2_256 only when >256 bytes. Layout is byte-identical to subxt /
  frame-decode.
- signing_host::create_transaction handles Product and LegacyAccount (with a
  fail-closed slot-zero key-match check); the product-facing entrypoint's
  caller-scoping, chain-submit permission, and user-confirmation gates already
  precede it.
- Extrinsic V5 (tx_ext_version != 0) returns the new AuthorityError::NotSupported
  -> HostCreateTransactionError::NotSupported: V5 general carries the signature
  inside a VerifySignature extension, which cannot come from pre-encoded parts.

Tested: v4 layout + signature verification, the >256 hashing boundary, extension
order preservation, and Product/LegacyAccount success plus v5/mismatch/no-session
rejections.
Resolve the headless-host conflicts, add statement-store allowance allocation for SSO, and refresh the headless diagnosis path.
Decode ring revisions correctly for Bulletin allowance claims.

Add CLI-managed signer accounts, network presets, and script-capable host modes.

Regenerate headless signing and pairing diagnosis reports with zero failures.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant