Skip to content

feat(auth): fix QR pairing and sign as the RFC-0022 product account (product-sdk-terminal 0.10.0) - #549

Merged
ReinhardHatko merged 3 commits into
rh/v2-contracts-and-gatefrom
rh/product-sdk-0.10-rfc0022
Sep 28, 2026
Merged

ReinhardHatko merged 3 commits into
rh/v2-contracts-and-gatefrom
rh/product-sdk-0.10-rfc0022

Conversation

@ReinhardHatko

Copy link
Copy Markdown
Collaborator

Stacked on #545 (which is stacked on #540) — review/merge those first.

Fixes QR pairing, which has been impossible on every shipping phone since mid-August, and migrates the CLI to the RFC-0022 product account. Both come from @parity/product-sdk-terminal@0.10.0, released 2026-09-23.

Why pairing was broken

@novasamatech/host-papp@0.9.0 moved the SSO envelope from P-256/AES-GCM to X25519/ChaCha20-Poly1305, shrinking the device key from 65 to 32 bytes. The handshake codec is fixed-width SCALE, so a 65-byte sender is undecodable rather than degraded — iOS showed "Something went wrong", Android the generic "invalid QR code". Every published terminal up to 0.8.2 pinned host-papp ^0.8.9, whose 0.x caret ceiling is the last release of the old wire, so no bump below 0.9 could have helped.

⚠️ Do not take 0.9.x on the way past. That is upstream #364 without #366: pairing succeeds, then checkMapping fails permanently because the phone mints PGAS to the RFC-0022 address while an unmigrated CLI checks the old one — and the UI reports it as transient, so it loops forever. 0.10.0 carries both.

What RFC-0022 changes

A product account now lives at //product//{productId}/{index} where the two //product//{productId} junctions are HARD. A public key cannot cross a hard junction, so the parent is a subtree key the wallet provides (session.getProductSubtree, consent-free, SDK-cached, reaching the phone only on a cold cache). deriveProductPublicKey and deriveSessionAddresses become async.

Two pre-merge predictions turned out wrong and are worth knowing:

  • ProductAccountRef.derivationIndex stayed a plain number. The tagged DerivationIndex lives inside @parity/product-sdk-keys, whose deriveProductAccountPublicKey(subtree, {tag,value}) is a 2-arg form — so the root moves to keys@^0.4.0.
  • publicKey on the ref is optional, and we omit it deliberately so the signer and the displayed address resolve through the same cached subtree and cannot desync.

Four defects caught by running it, not by CI

All four passed the unit suite and a clean build. The first two hid behind a typecheck that never ran: pnpm exec tsc was exiting inside pnpm's deps-status check (the release-age policy) before tsc started, so grepping its output for error TS reported success.

identityGate.ts read .productH160 off a Promise — every gated command refused with "Couldn't verify your builder status" on a healthy chain
DripScreen.tsx same missed await
resources.ts, smartContracts.ts SmartContractAllowance destination was a bare 0, not {tag:"Index",value:0}
sessionSigner.ts expired-allowance detection no longer matched the SDK's new shape

The allowance one is the subtle one: the grant still succeeds on the phone, so it looks healthy — but the SDK sub-keys its cache by destination, and an untagged value takes the Raw branch and hexes undefined. Every later read then died with undefined is not an object (evaluating 'bytes.length'), and playground login reported allowances ✕ on a perfectly good account. Latent before 0.10.0, which started reading that key.

The last one is a regression the bump itself introduced: 0.10.0 rejects with AllowanceExpiredError where 0.8.x logged and hung, so our wrapper stopped producing the message that carries the only remedy (re-pair — a renewal request would travel over the expired channel). It now handles both shapes, keeps the original as cause, and matches by name as well as instanceof, since a pnpm tree can hold two copies of the SDK.

Also: retries no longer look like progress

The phone-approval counter counted signature requests, so a timed-out request re-sent as "step 2" read as advancing — and contradicted the "Phone approvals expected" plan, which counts operations. Seen live: step 1: Link content then step 2: Link content while the plan said step 2 was the registry publish, and four prompts arrived for a two-approval deploy.

Verification

vitest 1034 passed · tsc --noEmit 0 (run directly — see above) · biome format clean · lint:license clean.

Live, against a real phone on paseo-next-v2:

  • fresh QR pairing ✅ — the outage is closed
  • product account unchanged (0x8755a050…), so nothing already published changed hands
  • allowances, mapping, identity gate ✅
  • phone-signed deploy of playground-template.paseo ✅ — new metadata CID, owner still the product account, which also confirms v2 republish authorization

Upgrade note

Users pair once more (playground logout + playground login). Anything already published stays owned by the old address. The new derivation is what current phones and the playground web app already use, so this brings the CLI back into agreement with them.

Follow-ups (tracked, not in this PR)

  • deploy-all is the one command not yet exercised on this stack — it's the only one with a signing gate serializing on-chain phases, which the async change touches.
  • The cold-cache subtree fetch failure path is unhandled: if the phone is unreachable the gate collapses to "Couldn't verify your builder status", which misdescribes the cause.

Moves to @parity/product-sdk-terminal@^0.10.0 (host-papp 0.10.0), which
carries product-sdk #364 and #366 together.

Pairing has been impossible since mid-August. host-papp 0.9.0 moved the SSO
envelope from P-256/AES-GCM to X25519/ChaCha20-Poly1305, shrinking the device
key from 65 to 32 bytes; the handshake codec is fixed-width, so a 65-byte
sender is undecodable rather than degraded — iOS said "Something went wrong",
Android "invalid QR code". Every terminal up to 0.8.2 pinned host-papp ^0.8.9,
whose 0.x ceiling is the last release of the old wire, so no bump below 0.9
could have helped.

Do NOT take 0.9.x on the way past: that is #364 without #366, where pairing
succeeds and then checkMapping fails permanently, because the phone mints PGAS
to the RFC-0022 address while an unmigrated CLI checks the old one — reported
in the UI as transient, so it loops forever.

RFC-0022 puts the product account at //product//{productId}/{index} with the
two //product//{productId} junctions HARD. A public key cannot cross a hard
junction, so the parent is now a subtree key the wallet provides, and the
derivation is async. `deriveSessionAddresses` becomes async with it.

Two things the pre-merge notes got wrong, both caught here:

  - `ProductAccountRef.derivationIndex` stayed a plain number. The tagged
    DerivationIndex lives inside @parity/product-sdk-keys, whose
    `deriveProductAccountPublicKey(subtree, {tag,value})` is a 2-arg form, so
    the root moves to keys ^0.4.0. On 0.3.x that call is a tsc error; bypass
    tsc and it derives from a stringified object instead. Worth noting because
    a vitest-only run does not typecheck — which is how it reached a test run
    at all.
  - `publicKey` on the ref is optional; we omit it deliberately so the signer
    and the address we display resolve through the same cached subtree and
    cannot desync.

Tests pin the derivation at the primitive against host-rust-core's own
cross-host vector (wasm_crypto_vectors.rs), not against our output — a fixture
regenerated from the code it checks is worthless. They also mock the SDK's
subtree fetch: unmocked it hangs waiting for a phone and writes a bogus key
into the developer's real ~/.polkadot-apps cache, breaking their live session.

`productAccount.test.ts` is deleted: the product id no longer enters that
primitive, so it could no longer test the wiring it claimed to, and its
assertions are subsumed by the exact vector test.

Verified against a real paired session: the built binary derives
0x8755a050e0b47300a7454942dac4f5dafd8222e5 — the address playground-app prints
and the owner of the apps deployed today.

Users pair again once (playground logout + playground login); anything already
published stays owned by the old address.
…ape, expiry path

Four defects found by running the migrated binary end to end against a real
phone. All of them passed the unit suite and a clean build; the first two were
hidden because `pnpm exec tsc` was exiting inside pnpm's deps-status check
(the release-age policy) before tsc ran, so a grep for "error TS" reported a
clean typecheck that never happened. Run tsc directly.

  - identityGate.ts read `.productH160` off a Promise, so every gated command
    (deploy, deploy-all, mod, decentralize) refused with "Couldn't verify your
    builder status" on a healthy chain.
  - DripScreen.tsx had the same missed await.
  - The SmartContractAllowance destination was a bare `0` instead of the
    tagged `{tag:"Index",value:0}`, in two places. The grant still succeeds on
    the phone, so it looks fine — but the SDK sub-keys its allowance cache by
    destination, and an untagged value takes the `Raw` branch and hexes
    `undefined`, so every later read died with "undefined is not an object
    (evaluating 'bytes.length')" and login reported "allowances ✕". Latent
    before 0.10.0, which started reading that key.
  - `wrapSignerWithSssFastFail` only detected the pre-0.10 expiry shape (a
    `console.error` line plus a hang). 0.10.0 REJECTS with
    `AllowanceExpiredError` instead, so the wrapper stopped producing the one
    message that carries the remedy — re-pairing, because a renewal request
    would travel over the expired channel. It now handles both, keeps the
    original as `cause`, and matches by name as well as `instanceof` (a pnpm
    tree can hold two copies of the SDK, where `instanceof` silently fails).

Deploy also stops presenting a retry as progress. The phone-approval counter
counted signature REQUESTS, so a timed-out request re-sent as "step 2" read as
forward progress and contradicted the plan above it, which counts operations.
Observed live: "step 1: Link content" then "step 2: Link content" while the
plan said step 2 was the registry publish, and four prompts arrived for a
two-approval deploy.

Verified against a real session: pairing, login, allowances, mapping, the
identity gate, phone signing, deploy, and re-publish of an app owned by the
product account (which also confirms v2 republish authorization).
@socket-security

socket-security Bot commented Sep 24, 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
Addednpm/​@​parity/​product-sdk-keys@​0.4.08510069100100
Updatednpm/​bulletin-deploy@​0.18.4 ⏵ 0.20.075 +6100100 +19670
Updatednpm/​@​parity/​product-sdk-terminal@​0.7.1 ⏵ 0.10.080 +5100100100 +1100

View full report

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

E2E Test Pass · ❌ FAIL

Tag: e2e-ci-pr · Branch: rh/product-sdk-0.10-rfc0022 · Commit: 61cd134 · Run logs

Cell Result Time
pr-login-session ❌ FAIL 4m24s
pr-deploy-foundry ✅ PASS 0m50s
pr-mod ❌ FAIL 2m51s
pr-deploy-cdm ❌ FAIL 2m35s
pr-preflight ❌ FAIL 2m34s
pr-deploy-frontend ❌ FAIL 3m09s
pr-install ✅ PASS 0m53s
${{ matrix.cell }} ⏭️ SKIP 0m00s
${{ matrix.cell }} ⏭️ SKIP 0m00s
❌ Failed tests (3)
  • e2e/cli/deploy.test.ts › dot deploy --playground — full pipeline (requires Paseo + IPFS) > frontend-only deploy completes end-to-end
    expected '\n ╭────────────────────────────────…' to contain 'Deploy complete'
  • e2e/cli/deploy.test.ts › dot deploy --playground — full pipeline (requires Paseo + IPFS) > re-deploy same domain succeeds for same owner
    expected '\n ╭────────────────────────────────…' to contain 'Deploy complete'
  • e2e/cli/deploy.test.ts › dot deploy --playground — full pipeline (requires Paseo + IPFS) > domain taken by another account shows unavailable
    bob deploy unexpectedly succeeded: ╭────────────────────────────────────────────────────────────────────────────╮ │ Join the competition first │ │ Playground commands are for builders who've joined the competition, so you │ │ need to be signed in first. │ │ │ │ Run playground login and scan the QR code with your Polkadot mobile app, │ │ then become a builder and join the competition at playground.dot in your │ │ desktop app. │ ╰────────────────────────────────────────────────────────────────────────────╯ : expected +0 not to be +0 // Object.is equality

Sentry traces: view spans for this run

…ing fix

bulletin-deploy 0.18.4 -> 0.20.0. Verified against paseo-next-v2 with a
dev-signer deploy of a NEW domain (bd-0200-devtest.paseo): build, chunk
upload, DotNS register, playground publish, owner recorded as the dev signer,
and the deployed URL returns HTTP 200. The HTTP check is the part that
matters — a deploy reporting success has never been sufficient evidence that
the CAR was parseable.

0.20.0 fixes a crash we actually hit: `JSON.parse` on incoming RPC data was
unguarded and threw inside the socket handler where nothing upstream could
catch it, killing the process mid-deploy. That is the
`SyntaxError: JSON Parse error: Unexpected EOF` at `_onMessage` seen during a
template deploy. 0.19.x additionally drops the storage-deposit floor for
registering a name from a hardcoded 200 PAS to 5 PAS, stops registers
reverting when the chain moves between dry-run and inclusion, and uploads
duplicate bundle files once instead of twice.

Checked per the invariant before pinning: `deploy()`'s signature is unchanged,
every `DeployOptions` field we pass still exists, the phase banners and `[N/M]`
chunk lines the progress parser matches are intact, and the `patch-package`
postinstall is still shipped (so the `allowBuilds` denial stays).

CLAUDE.md gains two notes from this session:

  - `pnpm <script>` can exit in its deps-status check BEFORE running the tool,
    which looks like a pass if you grep the output for "error TS". That is how
    four type errors reached a live phone test. Check the exit code or run the
    binary directly.
  - Mapping is not required to CALL a contract, and `checkMapping` returning
    false is normal for a plain Substrate signer. Every AccountId32 has a
    deterministic H160; `Revive.OriginalAccount` is only the reverse map.
    Contract fees can be paid in PAS, so neither PGAS nor mapping should be
    blamed for a rejected extrinsic without evidence — both were, wrongly.

The sentry scripts looked up keychain item `sentry-api-token`, which does not
exist; the real one is `sentry-api-token-playground`. All three would have
failed for anyone following the documented backup-before-change workflow.
@github-actions

Copy link
Copy Markdown
Contributor

Dev build ready — try this branch:

curl -fsSL https://raw.githubusercontent.com/paritytech/playground-cli/main/install.sh | VERSION=dev/rh/product-sdk-0.10-rfc0022 bash

@ReinhardHatko
ReinhardHatko merged commit 7ef58a6 into rh/v2-contracts-and-gate Sep 28, 2026
14 of 19 checks passed
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