Skip to content

Headless pairing + signing hosts#264

Draft
pgherveou wants to merge 153 commits into
mainfrom
headless-host
Draft

Headless pairing + signing hosts#264
pgherveou wants to merge 153 commits into
mainfrom
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 30 commits June 30, 2026 17:30
Adds the canonical testing module (api/testing.rs) and its v01/v02/versioned
wiring used by the Rust host runtime and generated clients.
New crate defining the host syscall traits (storage, navigation, consent,
permissions, ...) that host runtimes implement. Types are re-exported from
truapi::versioned/v01 rather than redefined.
WASM host runtime that hosts implement: dispatcher, SCALE frames, subscription
streams, chain runtime, host logic (sessions, SSO pairing, permissions,
statement store, dotns) and the wasm bindings. Includes the committed generated
dispatcher/wire-table under src/generated/.
…backs

Extends the rustdoc-JSON code generator to emit the Rust dispatcher and wire
table consumed by truapi-server, plus the TS host-callbacks adapter. Golden
tests pin the emitted shapes.
New WASM-backed host runtime package embedding the Rust core, with web iframe
and Web Worker entry points. Updates the @parity/truapi client (SCALE, sandbox,
transport) and drops the obsolete explorer 0.3.2 codegen snapshot.
Updates CLAUDE.md/README, CI workflows, Makefile, deny.toml, changesets, and
linguist attributes for generated code, and bumps the dotli submodule to the
host integration that consumes the WASM runtime.
Adds the canonical testing module (api/testing.rs) and its v01/v02/versioned
wiring used by the Rust host runtime and generated clients.
New crate defining the host syscall traits (storage, navigation, consent,
permissions, ...) that host runtimes implement. Types are re-exported from
truapi::versioned/v01 rather than redefined.
…backs

Extends the rustdoc-JSON code generator to emit the Rust dispatcher and wire
table consumed by truapi-server, plus the TS host-callbacks adapter. Golden
tests pin the emitted shapes.
decrypto21 added a commit that referenced this pull request Jul 8, 2026
@decrypto21 decrypto21 mentioned this pull request Jul 8, 2026
pgherveou added 16 commits July 8, 2026 15:33
Publish the host runtime as @parity/truapi-host and move the dotli diagnosis harness into the playground repo.
…t-core-port

# Conflicts:
#	rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts
#	rust/crates/truapi-codegen/tests/golden/host-callbacks.ts
#	rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts
#	rust/crates/truapi-codegen/tests/golden_rust_emit.rs
#	rust/crates/truapi-server/src/lib.rs
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.
@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.
Base automatically changed from bulletin-preimage-in-core to main July 14, 2026 05:20
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