Skip to content

Headless pairing + signing hosts#264

Draft
pgherveou wants to merge 6 commits into
worktree-issue-96-rust-core-portfrom
headless-host
Draft

Headless pairing + signing hosts#264
pgherveou wants to merge 6 commits into
worktree-issue-96-rust-core-portfrom
headless-host

Conversation

@pgherveou

@pgherveou pgherveou commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Adds a truapi-host CLI with pairing-host and signing-host commands:
a TrUAPI host — the product side and the wallet side — you can spin up from the
command line and drive with a JS/TS script. It pairs over the real
paseo-next-v2 People-chain statement store
(the same node an iOS/web client
uses); no local relay or node. (Plus the truapi-server work it needs — see below.)

Usage

Launch the two hosts (in two terminals, or background the first). The pairing
host serves a product and runs your script; the signing host answers the deeplink
it prints and signs:

# terminal 1 — pairing host: serves `myapp.dot`, runs the product script
truapi-host pairing-host --product-id myapp.dot --script whoami.ts
#   → prints:  PAIRING_DEEPLINK polkadotapp://pair?handshake=…

# terminal 2 — signing host: answers that deeplink and signs. The wallet mnemonic
# is --mnemonic, else $HOST_CLI_SIGNER_MNEMONIC, else the dev mnemonic (must be a
# registered LitePeople ring member).
truapi-host signing-host --deeplink 'polkadotapp://pair?handshake=…' --auto-accept

The script is top-level code; a thrown error fails the run:

// whoami.ts — top-level product script; `truapi` and `host` are injected globals
const login = await truapi.account.requestLogin({ reason: undefined });
if (!login.isOk() || login.value !== "Success") throw new Error("login failed");

// `truapi`: the @parity/truapi client, scoped to the host's --product-id
const user = await truapi.account.getUserId();
console.log("user id:", user.match((v) => v.primaryUsername, (e) => { throw e; }));

// `host`: keeps accounts in sync with --product-id — e.g. for a call that needs one:
//   truapi.signing.signRaw({ account: host.productAccount(0), payload: … })

Expected output (the signing host registers on-chain allowance, then both pair
and the script runs):

[pairing] FRAMES_LISTENING ws://127.0.0.1:9955
[pairing] PAIRING_DEEPLINK polkadotapp://pair?handshake=…
[signing] SIGNING_HOST_RING ring_index=16 members=255
[signing] SIGNING_HOST_ALLOWANCE wallet-sso seq=0 block=0x6fba67…
[signing] SIGNING_HOST_ALLOWANCE device   seq=1 block=0x529830…
[signing] SIGNING_HOST_READY
[pairing] user id: guestnrun.93

Both commands take --auto-accept; without it, every confirmation a web/iOS host
shows as a modal is prompted y/n on the CLI. The pairing host exits with the
script's status, so the command is the test.

Diagnose on-chain statement-store allowance for a mnemonic:

truapi-host alloc-check --lookback 100      # ring membership + free slot (read-only)

E2E

Two ready-made product scripts live in js/scripts/.

battery.ts — a signer gate (login + raw/payload signing, create-transaction,
entropy):

truapi-host pairing-host --product-id myapp.dot \
  --script rust/crates/truapi-host-cli/js/scripts/battery.ts --auto-accept
PASS  account.requestLogin         Success
PASS  account.getAccount           0xc0b6abd4f6b86dca
PASS  signing.signRaw(bytes)       0x4cc7eba6af60a905
PASS  signing.signRaw(message)     0x36d4735a9915dbbb
PASS  signing.signPayload          0x48ad551917975c8b
PASS  signing.createTransaction    210 chars
PASS  entropy.derive               0x6c79043d8bf65868
7/7 passed
GATE PASSED: 7 signer-critical cases

diagnosis.ts — runs the playground's own generated examples and writes
explorer/diagnosis-reports/headless.md. The examples are baked to
truapi-playground.dot, so serve that product:

truapi-host pairing-host --product-id truapi-playground.dot \
  --script rust/crates/truapi-host-cli/js/scripts/diagnosis.ts --auto-accept
# → 43 passed, 1 failed, 20 skipped (only the Chain/stop_transaction node limitation)

make headless builds the CLI + client; rust/crates/truapi-host-cli/e2e/run.sh
wraps a pairing host + signer (deeplink piped) for one-shot runs.

Roles (one truapi-host binary)

  • pairing-host — seedless PairingHostRuntime; serves product frames and
    runs your --script with truapi injected.
  • signing-host — wallet-local SigningHostRuntime; registers statement
    allowance on-chain, answers the handshake, and signs via the SSO responder.
  • identity-check — probe which derivation of a mnemonic carries a username.
  • alloc-check — diagnose (or --submit) on-chain statement-store allowance.

Both hosts default --statement-store to wss://paseo-people-next-system-rpc.polkadot.io.
Product ids must be a .dot/localhost identifier; the default is
headless-playground.dot.

Statement-store allowance (src/alloc/)

The real store enforces per-account allowance. The signing host grants it
on-chain exactly as a real client does, via an unsigned General (v5)
Resources.set_statement_store_account extrinsic (pallet 63 call 10) carrying a
bandersnatch ring-VRF membership proof. The native port covers:

  • metadata-driven signed-extension encoding + the inherited-implication proof
    message (0x00 ‖ call ‖ Σtail.extra ‖ Σtail.implicit), pinned by a
    known-answer test to the live-verified vector;
  • the ring-VRF one-shot proof (open/create via the verifiable prover);
  • AsResourcesInfo + the General-v5 envelope;
  • Members ring fetch (current index, paged RingKeys, included slice,
    ring_size → domain) and the SSS_SLOT slot scan;
  • a minimal WS JSON-RPC client with author_submitAndWatchExtrinsic.

Before pairing, the signing host registers allowance for the two accounts that
submit statements — its own //wallet//sso account and the pairing host's
per-pairing device key — proving its LitePeople ring membership once, and waits
for the registrations to finalize (the store does not honor a fresh allowance
until then). The signing account must be an attested member; because a
long-lived account sits in an older ring, the host scans back from the current
ring index to find it.

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

truapi-server additions

  • §B responder half: encode-side handshake helpers + responder codecs in
    host_logic/sso/, runtime/signing_host/sso_responder.rs.
  • sign_payload and create_transaction on the signing host, with v4 extrinsic
    assembly (host_logic/transaction.rs).
  • Product accounts + rootUserAccountId derive from //wallet (host-spec
    C.0/C.5) via an sr25519 hard-junction helper.
  • Bandersnatch ring-VRF aliases (get_account_alias) via the verifiable crate
    (native-only; wasm unaffected).
  • Lite-username attestation (host_logic/attestation.rs + CLI); get_user_id
    resolves a real People-chain username, and since SSO and identity share the
    People chain it always resolves.

Verification: cargo build/clippy -D warnings/test workspace-wide, cargo +nightly fmt --check, and make wasm all green; the full pairing + signer flow
passes live against paseo-next-v2, driven through the injected product script.

Notes for reviewers

  • The signer mnemonic is read from --mnemonic / $HOST_CLI_SIGNER_MNEMONIC /
    a gitignored e2e/.env; it is not committed. The dev mnemonic works out of the
    box (ring member, username guestnrun.93).
  • Not yet wired into make e2e-dotli.

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
@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
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