Skip to content

fix(deploy): follow DotNS ABI generations — bump bulletin-deploy, configure the availability probe - #540

Merged
ReinhardHatko merged 12 commits into
mainfrom
rh/fix-dotns-generation-drift
Sep 28, 2026
Merged

ReinhardHatko merged 12 commits into
mainfrom
rh/fix-dotns-generation-drift

Conversation

@ReinhardHatko

@ReinhardHatko ReinhardHatko commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Deploys on paseo-next-v2 failed for every signer with:

Contract execution would revert during startingPrice on POP_RULES
contract: 0x747B456bE03aec0b42bd85C51513730FBD45DA31
revert: flags=1 data=0x

Phone mode and --signer dev failed identically, with identical gas figures — which looked like a chain/contract outage but was not.

Cause: DotNS ships ABI generations

DotNS is redeployed in ABI generations behind the same contract addresses, and they roll out per environment, not per release. So a version diff of environments.json shows nothing while the on-chain ABI has moved underneath you.

Generation Registration tuple Deposit gate
poprules-startingPrice 4 fields flat PopRules.startingPrice()
v0.5.8-rc1 6 fields (+maxPrice, pricingVersion) per-label PopRules.price(label) — startingPrice() removed, now reverts
v0.6.0 identical encoding semantic change in name classification; only distinguishable by probing DotnsPopController.isPopIssued

Our pinned bulletin-deploy@0.15.0 knows only the first generation and calls startingPrice() unconditionally. 0.16+ probes the live generation at connect time.

Changes

  • Bump bulletin-deploy 0.15.0 → 0.18.4. Verified before bumping: index exports additive only, every DeployOptions field we pass still present, and the banner strings progress.ts parses unchanged (confirmed live — chunk 1/2, chunk 2/2 still render).

  • Fix the availability preflight. It constructed DotNS and connected with only rpc. With no contract map, DotNS merges nothing over its built-in defaults — whose POP_RULES has no code on this network — so 0.16+'s probe failed with "No contract deployed at this address". This was invisible before 0.16 because that version didn't probe, and the deploy path (runStorageDeploy) already passed env and resolved correctly. Two call sites, two levels of configuration.

    Now resolved through bulletin-deploy's own resolveEndpoints(doc, envId), which also supplies fields the first attempt missed — each failing silently rather than loudly:

    • tld — otherwise resolved on-chain with a "dot" fallback on revert, so checkOwnership would query <label>.dot while we report <label>.paseo.
    • autoAccountMapping — omitted, connect() takes the manual branch, which can submit a map_account extrinsic from a read-only path.
    • network — authoritative for isTestnet() instead of spec_name sniffing.
  • Widen the divergence guard to five contract keys. connect() merges over defaults, so a key missing upstream silently uses a dead default rather than erroring. DOTNS_POP_CONTROLLER is dereferenced by the profile probe too.

  • Clear withTimeout's timer (pre-existing leak): left pending it holds the event loop open for 30 s after the work completes — the "hangs after the work is visibly finished" symptom in CLAUDE.md. findAvailableRandomName calls it up to 20× per run.

  • Memoize the env resolution (loadEnvironments is uncached upstream).

  • CLAUDE.md: document the generations model and the two diagnosis traps — comparing contract addresses proves nothing, and two different signers failing identically means "our client is stale", not "the chain is down".

Verified live

A deploy that previously reverted at startingPrice now completes:

▸ storage-and-dotns…
  chunk 1/1
  chunk 1/2
  chunk 2/2
✔ storage-and-dotns

Bulletin upload and DotNS registration both succeed. The subsequent playground step fails separately because @w3s/playground-registry is not currently published to the CDM meta-registry on this env — unrelated to this PR, and being handled.

Note on a local-only typecheck error

While developing this I hit src/utils/deploy/playground.ts:509 — Option<Address>
owner vs contracts@0.10's HexString — and initially expected it to fail CI.

It does not: Typecheck passes in CI — but not for the reason first given here.
An earlier version of this note blamed a Node version difference (local 24.7.0 vs
CI 22). That was wrong.

The actual cause: .cdm/ is gitignored (.gitignore:4) and CI never generates it,
while tsconfig.json includes ./.cdm/**/*. That directory holds cdm install's
generated contracts.d.ts, which augments @parity/product-sdk-contracts with the
real contract signatures. Locally the augmentation is present, so tsc checks contract
calls against the actual ABI; in CI the include matches nothing and those call sites
go effectively unchecked, so the error cannot surface.

So CI's Typecheck job does not cover contract call sites at all — a contract ABI
change can land green and break every deploy at runtime.

Fixed in #545, though not the way suggested here: CI cannot run cdm install (the
cdm CLI is a global tool that needs chain access, not a repo dependency), so the two
generated .d.ts module augmentations are committed instead and the rest of .cdm/
stays ignored. That gap has since let two real defects through — a 7-argument publish
against a 4-argument contract, and a getMetadataUri shape change that broke
playground mod/init for every app.

The error itself is unrelated to this PR either way — that file is untouched, and the
lockfile diff contains only the bulletin-deploy line.

Testing

pnpm test 1022 passed · format:check ✓ · lint:license ✓ · typecheck — only the pre-existing error above.

A regression test now pins the connect() arguments. Without it the entire fix could be deleted with every other test still green.

…figure the availability probe

Deploys on paseo-next-v2 failed for every signer with "Contract execution
would revert during startingPrice on POP_RULES".

DotNS ships ABI *generations* redeployed behind the SAME contract addresses,
rolled out per environment rather than per release. Generation v0.5.8-rc1
removed PopRules.startingPrice(); bulletin-deploy 0.15.0 only knows the older
generation and calls it unconditionally. Since the addresses never change,
no version diff of environments.json showed anything.

- bump bulletin-deploy 0.15.0 -> 0.18.4 (probes the live generation at connect)
- availability preflight: resolve the env's DotNS contract map from
  bulletin-deploy's environments.json and pass it, plus environmentId, to
  DotNS.connect(). It previously passed only `rpc`, so DotNS fell back to a
  default map whose POP_RULES has no code here — invisible until 0.16 added
  the generation probe, because the deploy path (runStorageDeploy) already
  passed `env` and resolved correctly.
- config.test.ts: assert upstream ships that contract map, so a catalog
  change fails in CI instead of mid-deploy.

Verified end to end: storage-and-dotns completes (Bulletin chunks uploaded,
DotNS registered) where it previously reverted.

Note: `pnpm typecheck` still reports a pre-existing error at
playground.ts:509 (Option<Address> owner vs contracts@0.10's HexString),
unrelated to this change and tracked separately.
…ess review

Follow-up to adae9d5, from code review of that commit.

Two regressions introduced there:

- `dotnsContractsFor()` was awaited outside the try/catch, so a
  `loadEnvironments()` failure escaped `checkDomainAvailability` as a throw
  instead of the documented `{status:"unknown"}` — turning decentralize's
  warn-and-continue into a hard abort — and skipped `dotns.disconnect()`.
  Resolved inside the try now, still before `silenceConsole()`.
- The new mock's comment claimed connect() assertions that did not exist;
  the entire fix could be deleted with all 26 tests still green. Added an
  assertion pinning contracts/tld/network/autoAccountMapping/environmentId.

The hand-rolled env lookup is replaced by bulletin-deploy's own
`resolveEndpoints(doc, envId)`, which returns the contract map PLUS the
fields the first attempt omitted — each of which fails silently rather than
loudly:

- `tld`: connect() otherwise resolves it on-chain and falls back to "dot" on
  a revert, so checkOwnership would query `<label>.dot` while we report
  `<label>.paseo`.
- `autoAccountMapping`: omitted, connect() takes the manual-mapping branch,
  which can submit a `map_account` extrinsic from a read-only path.
- `network`: authoritative for isTestnet() instead of spec_name sniffing.

Also:

- memoize the resolution per env (loadEnvironments is uncached upstream and
  findAvailableRandomName calls it up to 20x per run)
- clear withTimeout's timer (pre-existing): left pending it holds the event
  loop open for the full 30s after the work completes
- guard five contract keys, not just POP_RULES — connect() merges over
  built-in defaults, so a missing key silently uses a default address with
  no code on this network. DOTNS_POP_CONTROLLER is dereferenced by the
  profile probe too; the previous comment claiming otherwise was wrong.
- correct three stale docs: the config.test.ts docblock that contradicted
  its own new test, the min-age justification still describing 0.15.0, and
  a CLAUDE.md claim citing evidence from the now-unpinned 0.15.0 dist.

Not addressed: the outer 30s withTimeout is no longer than one inner connect
attempt, so upstream's retries are unreachable and an abandoned race can leak
a WS client. Picking a new budget is a UX judgement call, left open.
@socket-security

socket-security Bot commented Sep 18, 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.15.0 ⏵ 0.20.075 +6100100 +19670
Updatednpm/​@​parity/​product-sdk-terminal@​0.7.1 ⏵ 0.10.080 +5100100100 +1100

View full report

@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/fix-dotns-generation-drift bash

@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

E2E Test Pass · ❌ FAIL

Tag: e2e-ci-pr · Branch: rh/fix-dotns-generation-drift · Commit: 642cb3d · Run logs

Cell Result Time
pr-mod ❌ FAIL 2m29s
pr-deploy-foundry ✅ PASS 0m44s
pr-login-session ❌ FAIL 4m23s
pr-deploy-frontend ❌ FAIL 3m02s
pr-deploy-cdm ❌ FAIL 2m29s
pr-preflight ❌ FAIL 2m28s
pr-install ✅ PASS 0m53s
${{ matrix.cell }} ⏭️ SKIP 0m00s
${{ matrix.cell }} ⏭️ SKIP 0m00s
❌ Failed tests (1)
  • e2e/cli/deploy.test.ts › dot deploy — preflight and validation &gt; domain availability check runs before build/upload
    availability banner not found: ╭────────────────────────────────────────────────────────────────────────────╮ │ 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 -1 to be greater than -1

Sentry traces: view spans for this run

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.
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.
@ReinhardHatko
ReinhardHatko marked this pull request as ready for review September 28, 2026 11:17
feat(auth): fix QR pairing and sign as the RFC-0022 product account (product-sdk-terminal 0.10.0)
feat: migrate to v2 playground contracts, env-scoped product id, and gate on the wired verifier
@ReinhardHatko
ReinhardHatko merged commit 9f3f879 into main Sep 28, 2026
15 of 20 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