Skip to content

feat: migrate to v2 playground contracts, env-scoped product id, and gate on the wired verifier - #545

Merged
ReinhardHatko merged 9 commits into
rh/fix-dotns-generation-driftfrom
rh/v2-contracts-and-gate
Sep 28, 2026
Merged

ReinhardHatko merged 9 commits into
rh/fix-dotns-generation-driftfrom
rh/v2-contracts-and-gate

Conversation

@ReinhardHatko

Copy link
Copy Markdown
Collaborator

Stacked on #540 — review/merge that first; this branch contains its two commits as a base.

Brings the CLI onto the v2 playground contracts and fixes two things that block real use today. Three commits, each independently green.

1. feat(contracts): migrate to the v2 playground registry

registry #525 changed publish to four args (domain, metadata_uri, visibility, modded_from) and changed the selector, so the previous seven-arg call reverts against the deployed contract. This release is required to publish at all.

The behavioural change worth knowing: the owner parameter is gone, so the registry always records env::caller(). Dev mode can no longer publish under the signed-in user's identity — claimedOwnerH160 is renamed userSessionH160 because it no longer claims anything, and the deploy summary now says plainly that a dev-mode app is owned by the dev signer and won't appear in MyApps. is_moddable / is_dev_signer are gone too; moddability lives in the metadata JSON's repository field.

Also fixes playground mod and playground init, which failed for every app — including ones that demonstrably existed — with App "<domain>" not found in registry. v2 returns getMetadataUri as a plain string (empty when absent) rather than an Option, and the value.isSome narrowing at both call sites read undefined every time. Decoding now lives in one helper that treats an unexpected payload as an error rather than silently reporting "not found".

2. feat(config): derive the product id from the environment TLD

PLAYGROUND_PRODUCT_ID becomes playground.${getEnvTld()} (so playground.paseo), matching what playground-app already ships.

Required for the CLI to work against a phone on a paseo environment at all: the mobile host re-validates the product id against its own TLD and the throw is swallowed, so a .dot id produced no prompt, no error and no reply — just the full ~180s deadline. Measured on one session, playground.paseo answered in ~3s while playground.dot never did.

⚠️ This is an identity migration. The product id is an input to the account derivation, so every product account address moves. Anything keyed on the old id (cached subtrees, the SDK allowance cache under ~/.polkadot-apps/) is re-granted at next login; apps published under the old address stay owned by it. The pinned test now asserts against getEnvTld() rather than a literal, so flipping ACTIVE_TESTNET_ENV can't silently desync the CLI from the app again.

3. fix(identity): gate on the verifier the registry actually consults

deploy, deploy-all, mod and decentralize refused with "Join the competition first" for users whose publish the chain would have accepted. The gate read getRootAccount from the identity spine, but the registry's require_revealed() delegates to whatever set_verifier names — currently an open verifier that accepts every account. The CLI was enforcing a stricter rule than the chain, against a different contract.

It now resolves registry.getVerifier() and calls isVerified on that address, which is correct both today and on a competition deployment that wires the spine, with no flag to keep in sync. Per ADR-0011 it asks the verifier a question rather than comparing its address — three implementations exist and an operator picks one per event, so any address comparison is correct only until the next one.

The option to inject a pre-resolved contract is removed, not renamed. mod injected its registry handle as an optimisation; the narrowing is a cast so tsc stayed silent, and every mod run reported "couldn't verify your builder status" on a healthy chain. Removing the parameter makes that unrepresentable.

Verification

Beyond pnpm test / typecheck / format:check / lint:license (all green, 1026 tests, each commit verified in isolation), this was exercised against paseo-next-v2:

  • Phone-mode moddable deploy of the playground template → playground-template.paseo, owned by the user's product account, confirming v2 ownership semantics.
  • playground init end to end — resolves the registry entry, fetches metadata from IPFS, clones, installs 112 packages, and the scaffolded project builds clean. This is the path that was broken for every app before commit 1.
  • Identity gate passes on both deploy and mod with no bypass.

Follow-ups (not in this PR)

  • CI's Typecheck job does not cover contract call sites. .cdm/ is gitignored and never generated in CI while tsconfig.json includes ./.cdm/**/*, so the generated contract signatures are absent and those calls go unchecked. An ABI change can land green and break every deploy at runtime — this is why the getMetadataUri regression above was invisible. See the corrected note on fix(deploy): follow DotNS ABI generations — bump bulletin-deploy, configure the availability probe #540.
  • deploy --moddable records the current branch without checking it exists on the remote, so deploying from an unpushed feature branch publishes an app nobody can mod.
  • Removing decentralize is deliberately left to a separate PR.

registry #525 changed `publish` to four arguments
(domain, metadata_uri, visibility, modded_from) and changed the selector,
so the previous seven-argument call reverts against the deployed contract.

The dropped `owner` parameter is the behavioural change worth knowing: the
registry now always records `env::caller()`, so dev mode can no longer
publish under the signed-in user's identity. `claimedOwnerH160` was renamed
`userSessionH160` because it no longer claims anything — it only drives the
deploy summary, which now says plainly that a dev-mode app is owned by the
dev signer and won't show in MyApps. `is_moddable` and `is_dev_signer` are
gone too; moddability lives in the metadata JSON's `repository` field.

Also fixes `playground mod` and `playground init`, which failed with
`App "<domain>" not found in registry` for every app, including ones that
exist. v2 returns `getMetadataUri` as a plain string (empty when absent)
rather than an Option, and the `value.isSome` narrowing at both call sites
read `undefined` every time. Decoding now lives in one helper that treats an
unexpected payload as an error instead of silently reporting "not found" —
the shape drift was invisible to tsc because both call sites were untyped.
`PLAYGROUND_PRODUCT_ID` becomes `playground.${getEnvTld()}` — so
`playground.paseo` on paseo-next-v2 — instead of a fixed `playground.dot`.

This is an identity migration: the product id is an input to the account
derivation, so every product account address moves. Anything keyed on the
old id (cached subtrees, the SDK allowance cache under `~/.polkadot-apps/`)
is not found under the new one and is re-granted at the next login. Apps
published under the old address stay owned by it.

It is also required for the CLI to work against a phone on a paseo
environment at all. The mobile host re-validates the product id against its
own TLD in the ProductSubtreeRequest mapper and the throw is swallowed, so a
`.dot` id produced no prompt, no error and no reply — just the full ~180s
deadline. Measured on one session: `playground.paseo` answered in ~3s while
`playground.dot` and `playground.testnet` never did.

playground-app already ships the environment-suffixed form, and the two must
match exactly or the CLI and the app derive different H160s for the same
person, splitting app ownership and XP across two identities.

The pinned test now asserts against getEnvTld() rather than a literal, so
flipping ACTIVE_TESTNET_ENV cannot silently desync the two again. The frozen
address vector was regenerated; the old id still reproduces the old pair,
confirming only the input changed.
deploy, deploy-all, mod and decentralize refused with "Join the competition
first" for users whose publish the chain would have accepted.

The gate read `getRootAccount` from the identity spine. The registry does
not consult the spine — `require_revealed()` delegates to whatever
`set_verifier` names, which on this deployment is an open verifier that
returns true for every account. The CLI was enforcing a stricter rule than
the chain, against a different contract, so the two drifted the moment the
deployment wired something else.

It now resolves `registry.getVerifier()` and calls `isVerified` on that
address, which is correct both today and on a competition deployment that
wires the spine, with no flag to keep in sync. The verifier is asked a
question rather than identified by address: ADR-0011 names three
implementations and an operator picks one per event, so any
`verifier === <known address>` check is correct only until the next one.
The minimal ABI is attached at runtime so this also works for a verifier
that is not in our cdm.json.

The option to inject a pre-resolved contract is REMOVED rather than
renamed. It existed so a caller holding a registry handle could skip a
second meta-registry resolution, and it cannot work any more: the gate
reads `getVerifier()` and then queries a different contract, so no handle a
caller already has is the right one. `mod` injected its registry anyway —
the narrowing is a cast, so tsc stayed silent — and every `mod` run
reported "couldn't verify your builder status" on a healthy chain. Removing
the parameter makes that unrepresentable instead of merely discouraged;
tests mock `getVerifierContract`.

`IdentityRegistry` is likewise renamed `PersonhoodVerifier`, since the old
name is what made passing a registry look right.

`getReadOnlyIdentityContract` is kept but documented as deliberately unused
— the spine is real and a reveal flow will need it, but it must not come
back as the gate.

Adds coverage for `getVerifierContract`, including that it follows whatever
`getVerifier()` returns rather than a hardcoded address, so a future
"optimisation" to a constant fails the build.
@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/v2-contracts-and-gate bash

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

E2E Test Pass · ❌ FAIL

Tag: e2e-ci-pr · Branch: rh/v2-contracts-and-gate · Commit: e53971f · Run logs

Cell Result Time
pr-deploy-frontend ❌ FAIL 2m49s
pr-deploy-foundry ✅ PASS 1m33s
pr-deploy-cdm ❌ FAIL 2m30s
pr-mod ❌ FAIL 2m47s
pr-preflight ❌ FAIL 2m30s
pr-install ✅ PASS 0m48s
pr-login-session ❌ FAIL 4m36s
${{ matrix.cell }} ⏭️ SKIP 0m-1s
${{ matrix.cell }} ⏭️ SKIP 0m-1s

Sentry traces: view spans for this run

CI's Typecheck failed on this PR: `Argument of type 'Contract<ContractDef>'
is not assignable to parameter of type 'MetadataUriReader'`.

The registry handle only carries its per-method types when `.cdm/` has been
generated by `cdm install`. That directory is gitignored and CI never
creates it, so there the handle is a bare `Contract<ContractDef>` with no
`getMetadataUri` at all — no structural interface can match it. Locally,
with `.cdm/` present, the same code type-checks cleanly, which is why this
could not be caught before pushing.

Cast at the single call site. `getAppMetadataUri` validates the response
shape at runtime and throws on anything unexpected, so the cast costs no
real safety.

Verified under both conditions by temporarily hiding `.cdm/` and re-running
`tsc`: zero errors with and without it.
`.cdm/` was gitignored wholesale and CI never generated it — the `cdm` CLI is
a global tool that needs chain access, not a repo dependency. So the
`Typecheck` job was checking everything EXCEPT the contract call sites: with
no module augmentation, every registry handle is a bare
`Contract<ContractDef>` and `.query()`/`.tx()` calls go unchecked.

That is not hypothetical. It hid the 7-argument `publish` call against a
4-argument v2 contract, and a `getMetadataUri` shape bug that broke
`playground mod` and `playground init` for every app. It also produces the
inverse trap, which this PR hit: code that compiles locally fails in CI,
because a structural interface cannot match the un-augmented type.

Commit only the two `.d.ts` module augmentations. The rest of `.cdm/` stays
ignored: `contracts/**/info.json` snapshots per-version contract addresses we
never read (they resolve live from the CDM meta-registry), so committing them
would add a second, stale source of truth.

Verified by hiding `.cdm/contracts/` — leaving exactly what CI will have — and
re-running `tsc`: zero errors.

The license check now skips `.cdm/`. Those files are generated and marked "do
not edit", so a header would be stripped by the next `cdm i` and turn the
check red on a routine regeneration.
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).
…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.
feat(auth): fix QR pairing and sign as the RFC-0022 product account (product-sdk-terminal 0.10.0)
@ReinhardHatko
ReinhardHatko merged commit 24adcb7 into rh/fix-dotns-generation-drift Sep 28, 2026
13 of 18 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