Skip to content

Headless pairing + signing hosts#264

Draft
pgherveou wants to merge 15 commits into
bulletin-preimage-in-corefrom
headless-host
Draft

Headless pairing + signing hosts#264
pgherveou wants to merge 15 commits into
bulletin-preimage-in-corefrom
headless-host

Conversation

@pgherveou

@pgherveou pgherveou commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Get Started

The fast path is two terminals: install the CLI, run the product-side host with a script, then let a wallet-side signing host answer the deeplink it prints.

make headless install

# terminal 1 - seedless product/pairing host
truapi-host pairing-host \
  --product-id myapp.dot \
  --script whoami.ts \
  --auto-accept
# prints: PAIRING_DEEPLINK polkadotapp://pair?handshake=...

# terminal 2 - wallet-local signing host
# Omit --mnemonic and --account to let the CLI select/create an attested signer.
truapi-host signing-host \
  --deeplink 'polkadotapp://pair?handshake=...' \
  --auto-accept

For the common e2e case, run.sh wraps both processes and pipes the deeplink for you:

make headless install
rust/crates/truapi-host-cli/e2e/run.sh
rust/crates/truapi-host-cli/e2e/run.sh path/to/my-script.ts

A product script is top-level JS/TS. The runner injects truapi, scoped to --product-id, and a small host helper for product accounts:

const login = await truapi.account.requestLogin({ reason: undefined });
if (!login.isOk() || !["Success", "AlreadyConnected"].includes(String(login.value))) {
  throw new Error("login failed");
}

const user = await truapi.account.getUserId();
console.log("user id:", user.match((v) => v.primaryUsername, (e) => { throw e; }));

const sig = await truapi.signing.signRaw({
  account: host.productAccount(),
  payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } },
});
sig.match((v) => console.log(v.signature), (e) => { throw new Error(JSON.stringify(e)); });

This PR adds the truapi-host CLI plus the truapi-server native runtime work needed for both host roles. It replaces the external signing-bot dependency for local headless e2e: the two CLI processes pair over the real paseo-next-v2 People-chain statement store and can exercise signing, statement-store, preimage/Bulletin, identity, and chain flows against live RPCs.

CLI Shape

One binary has four commands:

Command Purpose
pairing-host Seedless product-side host. Serves product frames, emits pairing deeplinks, and runs product scripts.
signing-host Wallet-local host. Owns signer identity, can run product scripts directly, accepts pairing deeplinks, registers resource allowance, and signs.
identity-check Probes which derivation of a mnemonic has a registered Lite username.
alloc-check Diagnoses or submits People-chain Statement Store allowance for a mnemonic.

There is no public --statement-store flag. --network selects the identity backend, People RPC, Bulletin RPC, genesis hashes, and account namespace. It defaults to paseo-next-v2.

pairing-host

Flag Default Behavior
--script <path> interactive mode Runs a product script and exits with its status. If omitted, starts pairing-host> where script <path> can be run repeatedly.
--product-id <id> headless-playground.dot Product id used for frames, storage, permissions, and product accounts. Must be a .dot or localhost id.
--frame-listen <addr> 127.0.0.1:9955 WebSocket frame endpoint for the injected JS client.
--network <name> paseo-next-v2 Network preset for People/Bulletin RPC and genesis config.
--base-path <path> $TRUAPI_HOST_BASE_PATH, else XDG state Host state root. Pairing host state is stored under the selected network.
--auto-accept off Auto-approves prompts that would otherwise be shown as CLI y/n confirmations.

signing-host

Flag Default Behavior
--script <path> interactive mode Runs a product script directly against the signing host frame server and exits with its status. If omitted, starts signing-host>.
--deeplink <url> none Immediately accepts one pairing deeplink. If omitted, nothing is paired automatically; interactive mode accepts deeplink <url> or a raw polkadotapp://pair?... line.
--product-id <id> headless-playground.dot Product id for direct scripts and product-scoped operations.
--mnemonic <phrase> $HOST_CLI_SIGNER_MNEMONIC, else none Uses an explicit wallet mnemonic. Any mnemonic bypasses account auto-management. Cannot be combined with --account or --lite-username-prefix.
--account <name> none Uses a named stored account from accounts.json. This is for explicit reuse; auto mode is selected by omitting both --account and mnemonic.
--lite-username-prefix <prefix> headless Prefix for newly-created Lite usernames in auto-account mode only.
--base-path <path> $TRUAPI_HOST_BASE_PATH, else XDG state Root for accounts.json and host state. Account records are network-scoped.
--network <name> paseo-next-v2 Network preset for identity, People, and Bulletin.
--frame-listen <addr> 127.0.0.1:9956 WebSocket frame endpoint when running direct signing-host scripts.
--auto-accept off Auto-approves signing, permission, and resource prompts.

Interactive mode stays alive until quit/exit/q. pairing-host> supports script <path>. signing-host> supports script <path> and deeplink <url>.

Auto-Managed Signers

The signing host owns signer readiness instead of relying on one hard-coded mnemonic.

Resolution order:

  1. --mnemonic or HOST_CLI_SIGNER_MNEMONIC: use that mnemonic exactly. No account creation or rotation.
  2. --account <name>: load that named account for the selected --network, ensure it is attested and ring-ready, then use it.
  3. neither mnemonic nor account: auto-select an attested stored account that has not exhausted this Statement Store period, or create a fresh one.

Fresh auto accounts are generated under --base-path/<network>/accounts.json, attested through the identity backend, and waited on until People-chain ring membership is visible. The store contains plaintext local test mnemonics and is written with 0600 permissions on Unix. If a pairing run hits Statement Store slot exhaustion, the account is marked exhausted for the current period and the signing host rotates to another auto-managed account.

The CLI deliberately does not use --account auto: omitting --account is auto mode. That keeps --account <name> reserved for explicit stored-account reuse.

E2E Scripts And Reports

Ready-made scripts live in rust/crates/truapi-host-cli/js/scripts/:

Script Purpose
battery.ts Fast signer gate: login, account, raw signing, payload signing, create-transaction, entropy. This is run.sh's default.
diagnosis.ts Runs the playground generated examples and writes a report shaped like explorer/diagnosis-reports/ios.md.
signing-smoke.ts Small direct signing-host smoke test.
preimage-smoke.ts Focused preimage submit/lookup smoke test.

Diagnosis now has separate checked-in reports for both host paths:

Report Result
explorer/diagnosis-reports/headless-signing.md 44 passed, 0 failed, 20 skipped
explorer/diagnosis-reports/headless-pairing.md 44 passed, 0 failed, 20 skipped

The remaining skipped rows are deferred/non-native feature areas, not the previous diagnosis failures. The formerly failing Chain/stop_transaction, Preimage/lookup_subscribe, Preimage/submit, Resource Allocation/request, Statement Store/subscribe, legacy signing, and Account/get_user_id rows are passing in both reports.

Resource Allowance

The real statement store enforces per-account allowance. Before answering a pairing deeplink, the signing host grants Statement Store allowance on-chain for both accounts that submit statements: its own //wallet//sso account and the pairing host's per-pairing device key. It proves LitePeople membership with a bandersnatch ring-VRF and submits the unsigned General v5 Resources.set_statement_store_account extrinsic, then waits for finality before pairing continues.

Preimage flows now work because the signing host also allocates product-scoped Bulletin long-term storage allowance over SSO. Preimage/submit and Preimage/lookup_subscribe no longer depend on a CLI sentinel key with no Bulletin capacity.

The low-level allowance code keeps using subxt-rpcs for RPC and subscription transport. It still builds dynamic storage keys/extrinsics where no generated static interface exists in this workspace: metadata-driven signed-extension encoding, ring fetch, included-member slicing, slot scans, ring-VRF proof generation, extrinsic assembly, submit-and-watch, and Bulletin authorization polling.

Recent review fixes in this area:

  • Bulletin authorization waiting now accepts refreshes that restore usable size or expiry without increasing the remaining transaction count.
  • alloc-check --submit now requires --target; the all-zero target remains read-only scan-only behavior.
  • CLI allowance diagnostics and auto-account attestation polls now use subxt-rpcs instead of hand-rolled websocket request-id/subscription/storage polling.
  • BulletinRpc::client preserves RuntimeFailure; it is stringified only at the SSO response boundary where the protocol type requires text.

Runtime Changes

truapi-server additions in this PR include:

  • SSO responder support for native signing hosts, including Statement Store and Bulletin resource allocation responses.
  • Direct signing-host frame execution with the same runtime services used by paired mode.
  • Local activation with the stored Lite username so get_user_id resolves for CLI-managed accounts.
  • Legacy account signing and transaction creation from the validated product slot-zero key.
  • Product account derivation, transaction assembly, ring-VRF aliases, and native chain/Bulletin runtime plumbing needed by the headless hosts.
  • Stricter statement-store submit handling so NoAllowance and BadProof surface instead of being treated as accepted submissions.

Verification

Ran before the diagnosis-report push:

cargo test -p truapi-server signing_host --lib
cargo test -p truapi-server statement_allowance --lib
cargo build -p truapi-host-cli --release

Live checks against paseo-next-v2:

  • direct signing-host preimage smoke passed
  • paired signing/pairing preimage smoke passed
  • direct signing-host diagnosis: 44 passed, 0 failed, 20 skipped
  • paired diagnosis: 44 passed, 0 failed, 20 skipped

Latest review pass:

cargo test -p truapi-server statement_allowance --lib
cargo test -p truapi-server signing_host --lib
cargo build -p truapi-host-cli
cargo build -p truapi-host-cli --release
git diff --check
make e2e-dotli

Other checks:

  • truapi-host alloc-check --mnemonic <valid mnemonic> --submit exits before connecting with --target is required with --submit; the all-zero default is read-only.
  • make e2e-dotli completed with signer-bot on paseo-next-v2 and produced 44 success · 0 failed; generated report: playground/test-results/e2e-dotli/diagnosis-report.md.
  • e2e-dotli now builds the host WASM with the release profile by default because the dev WASM exceeds the dotli PWA size limit. Normal dev-bootstrap still defaults to the dev profile and can be overridden with TRUAPI_WASM_PROFILE.

Reviewer Notes

  • The auto-account store is local test state, not a production wallet store.
  • --network is the extension point for future chains; adding one should define all RPC/backend/genesis config in one preset.
  • --deeplink is optional by design. If omitted, the signing host either runs the provided script directly or waits in interactive mode for a pasted deeplink.
  • --mnemonic and HOST_CLI_SIGNER_MNEMONIC are still supported for deterministic local runs, but they intentionally disable auto account selection/creation.

pgherveou added 2 commits July 5, 2026 12:40
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).
@socket-security

socket-security Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcargo/​reqwest@​0.12.287910094100100
Addedcargo/​tokio-tungstenite@​0.24.09910093100100

View full report

pgherveou added 4 commits July 5, 2026 21:36
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`.
@pgherveou pgherveou changed the title Headless pairing + signing hosts for local e2e (signing-bot replacement) Headless pairing + signing hosts Jul 6, 2026
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).
@TarikGul TarikGul self-requested a review July 7, 2026 15:46
decrypto21 added a commit that referenced this pull request Jul 8, 2026
@decrypto21 decrypto21 mentioned this pull request Jul 8, 2026
Resolve the headless-host conflicts, add statement-store allowance allocation for SSO, and refresh the headless diagnosis path.
@pgherveou pgherveou changed the base branch from worktree-issue-96-rust-core-port to bulletin-preimage-in-core July 11, 2026 13:26
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