Repository navigation
feat: migrate to v2 playground contracts, env-scoped product id, and gate on the wired verifier - #545
Merged
ReinhardHatko merged 9 commits intoSep 28, 2026
Conversation
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.
Contributor
|
Dev build ready — try this branch: |
Contributor
E2E Test Pass · ❌ FAILTag:
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
merged commit Sep 28, 2026
24adcb7
into
rh/fix-dotns-generation-drift
13 of 18 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 registryregistry #525 changed
publishto 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
ownerparameter is gone, so the registry always recordsenv::caller(). Dev mode can no longer publish under the signed-in user's identity —claimedOwnerH160is renameduserSessionH160because 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_signerare gone too; moddability lives in the metadata JSON'srepositoryfield.Also fixes
playground modandplayground init, which failed for every app — including ones that demonstrably existed — withApp "<domain>" not found in registry. v2 returnsgetMetadataUrias a plain string (empty when absent) rather than anOption, and thevalue.isSomenarrowing at both call sites readundefinedevery 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 TLDPLAYGROUND_PRODUCT_IDbecomesplayground.${getEnvTld()}(soplayground.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
.dotid produced no prompt, no error and no reply — just the full ~180s deadline. Measured on one session,playground.paseoanswered in ~3s whileplayground.dotnever did.~/.polkadot-apps/) is re-granted at next login; apps published under the old address stay owned by it. The pinned test now asserts againstgetEnvTld()rather than a literal, so flippingACTIVE_TESTNET_ENVcan't silently desync the CLI from the app again.3.
fix(identity): gate on the verifier the registry actually consultsdeploy,deploy-all,modanddecentralizerefused with "Join the competition first" for users whose publish the chain would have accepted. The gate readgetRootAccountfrom the identity spine, but the registry'srequire_revealed()delegates to whateverset_verifiernames — 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 callsisVerifiedon 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.
modinjected its registry handle as an optimisation; the narrowing is a cast so tsc stayed silent, and everymodrun 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:playground-template.paseo, owned by the user's product account, confirming v2 ownership semantics.playground initend 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.deployandmodwith no bypass.Follow-ups (not in this PR)
Typecheckjob does not cover contract call sites..cdm/is gitignored and never generated in CI whiletsconfig.jsonincludes./.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 thegetMetadataUriregression above was invisible. See the corrected note on fix(deploy): follow DotNS ABI generations — bump bulletin-deploy, configure the availability probe #540.deploy --moddablerecords the current branch without checking it exists on the remote, so deploying from an unpushed feature branch publishes an app nobody can mod.decentralizeis deliberately left to a separate PR.