From 354c7f77387b5e582f8daeea7dd2bbec9fa3855c Mon Sep 17 00:00:00 2001 From: Reinhard Hatko Date: Sat, 1 Aug 2026 18:16:24 +0200 Subject: [PATCH] feat: migrate to the new playground-registry + identity-spine contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry was ported onto pvm-contract-sdk with identity split out into a global @w3s/playground-identity spine, changing the ABI shapes and the publish authorization model: - publish/publishDev are separate methods again: publishDev (6 args) is authorized by env::caller() matching a compiled-in dev signer and is exempt from the reveal gate; publish is gated FAIL-CLOSED by require_revealed() and ignores its trailing is_dev_signer flag. Routing keys on the signer's H160 (isKnownDevPublishSigner), not on --suri provenance, in BOTH publish pipelines (deploy + decentralize), so arbitrary suri keys (e.g. the e2e deployer) publish as reveal-gated users. - The owner arg is a plain Address (zero = none; shared ZERO_H160 constant) — Option is not a SolType. The override is honored only on publishDev; publishToPlayground rejects claimedOwnerH160 without isDevSigner before any on-chain work. String reads return "" as the none sentinel, decoded in one place (queryMetadataUri, with an ABI-drift guard). - The builder-identity gate reads spine.isVerified(productH160) via a dedicated identity-only resolution (registry-only reads stay decoupled from spine availability); deterministic ABI drift fails fast instead of burning the retry budget. - NotRevealed/Unauthorized reverts surface actionable remedies keyed on the decoded revert reason. cdm.json carries the new ABIs (spliced from the contract build artifacts); refresh with `cdm i` once the migrated contracts are deployed and published to CDM — this must ship in lockstep with that deploy. The e2e deployer must be seeded in the spine (admin_set_identity) before the publish e2e legs can pass; documented in docs/e2e-bootstrap.md. --- .changeset/shiny-spines-reveal.md | 5 + CLAUDE.md | 9 +- cdm.json | 1109 +++++++++++----------- docs/e2e-bootstrap.md | 12 + e2e/cli/deploy.test.ts | 11 +- e2e/cli/fixtures/registry.ts | 31 +- src/commands/mod/SetupScreen.tsx | 10 +- src/commands/mod/index.ts | 29 +- src/commands/shared/gateOrNotice.test.ts | 8 +- src/commands/shared/gateOrNotice.ts | 6 +- src/utils/contractManifest.ts | 46 +- src/utils/decentralize/run.test.ts | 43 + src/utils/decentralize/run.ts | 7 +- src/utils/deploy/playground.test.ts | 124 ++- src/utils/deploy/playground.ts | 140 ++- src/utils/deploy/run.ts | 6 +- src/utils/deploy/signerMode.test.ts | 27 + src/utils/deploy/signerMode.ts | 26 +- src/utils/identity/identityGate.test.ts | 108 +-- src/utils/identity/identityGate.ts | 118 ++- src/utils/registry.test.ts | 88 +- src/utils/registry.ts | 106 ++- tools/list-registry-apps.ts | 3 + tools/register-e2e-fixtures.ts | 12 +- 24 files changed, 1277 insertions(+), 807 deletions(-) create mode 100644 .changeset/shiny-spines-reveal.md diff --git a/.changeset/shiny-spines-reveal.md b/.changeset/shiny-spines-reveal.md new file mode 100644 index 00000000..d9e10e94 --- /dev/null +++ b/.changeset/shiny-spines-reveal.md @@ -0,0 +1,5 @@ +--- +"playground-cli": minor +--- + +Migrate to the new playground-registry and identity-spine contracts (pvm-contract-sdk port). Dev deploys now publish through the registry's separate `publishDev` method (authorized by the known dev-signer account, exempt from the reveal gate); user publishes go through the reveal-gated `publish` and require the account to be revealed as a builder via the playground app. The builder-identity gate now reads the new `@w3s/playground-identity` spine contract, `--suri` deploys with a non-dev key publish as regular (reveal-gated) users, and a `NotRevealed` revert surfaces an actionable "Become a builder" hint instead of an opaque failure. diff --git a/CLAUDE.md b/CLAUDE.md index f032cc92..b0651960 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,14 +33,15 @@ These aren't self-evident from reading the code and have bitten us before. Treat ### Network / env - **`ACTIVE_TESTNET_ENV` (`src/config.ts`) is THE network switch — flipping it is the entire "change networks" PR.** It feeds both `DEFAULT_ENV` and the legacy `testnet` alias in `resolveLegacyEnv`, so one constant moves the whole CLI. Only `paseo-next-v2` is wired in `CONFIGS` today; other envs throw "not supported" from `getChainConfig()`. (Summit / w3s was retired in July 2026 — see the retirement note below.) The deploy `--env` flag accepts the new ids plus the legacy `testnet|mainnet` aliases. NOTE: the direct-chain layer (`getConnection()`) is hardwired to `getChainConfig()` (DEFAULT_ENV) and the `paseo_*` descriptors — `--env` only reroutes what we hand polkadot-app-deploy's `deploy()`, not our own reads. When adding an env, populate `CONFIGS` (every field, including `cdmEnvName` and `tokenSymbol`) and verify descriptors exist in `@parity/product-sdk-descriptors`. As of `descriptors@0.8.0` the ONLY exported descriptor set is `paseo-*` (the `summit-*` subpaths were dropped upstream), so `src/utils/descriptors.ts` returns the paseo descriptor for EVERY env — direct reads touch common pallets only, so that is safe; restore per-env selection there if another env regains dedicated descriptors. Each env also carries a display-only `tokenSymbol` (paseo-next-v2 → `PAS`); read it via `getTokenSymbol()` (the sole consumer is `formatPas` in `src/utils/account/drip.ts`), so flipping `ACTIVE_TESTNET_ENV` relabels every balance/drip amount automatically. It is NOT validated by the `config.test.ts` divergence guard — environments.json carries no symbol — so set it from the chain's own token, not upstream. -- **All chain URLs / contract addresses live in `src/config.ts` — EXCEPT the CDM meta-registry address, which lives in `@parity/cdm-env` and resolves per-env via `getRegistryAddress(cfg.cdmEnvName)`.** Never inline a websocket URL or `0x…` address anywhere else. `config.ts` carries a `cdmEnvName` per env (the name cdm-env keys on — paseo passes through as `paseo-next-v2`; the split matters when an env's product-sdk name differs from cdm-env's key, as the retired `summit`/`w3s` pair did); `src/utils/registry.ts` and `src/commands/contract.ts` resolve the meta-registry root from it and inject it over `cdm.json::registry` (which is just whatever `cdm i` baked — do NOT hand-edit `cdm.json`). `getRegistryAddress` returns `""` for an unknown/undeployed env, and `registry.ts` throws a clear "bump @parity/cdm-env" error on empty. +- **All chain URLs / contract addresses live in `src/config.ts` — EXCEPT the CDM meta-registry address, which lives in `@parity/cdm-env` and resolves per-env via `getRegistryAddress(cfg.cdmEnvName)`, and the registry contract's compiled-in dev-signer H160s (`KNOWN_DEV_SIGNER_H160S`), which live in `src/utils/contractManifest.ts` because they are contract identity (env-independent constants baked into the deployed registry), not per-env wiring.** Never inline a websocket URL or `0x…` address anywhere else. `config.ts` carries a `cdmEnvName` per env (the name cdm-env keys on — paseo passes through as `paseo-next-v2`; the split matters when an env's product-sdk name differs from cdm-env's key, as the retired `summit`/`w3s` pair did); `src/utils/registry.ts` and `src/commands/contract.ts` resolve the meta-registry root from it and inject it over `cdm.json::registry` (which is just whatever `cdm i` baked — do NOT hand-edit `cdm.json`; the one sanctioned exception is the pre-deploy ABI splice described in the identity-spine bullet under Deploy / Bulletin, to be replaced by `cdm i` once the migrated contracts are published). `getRegistryAddress` returns `""` for an unknown/undeployed env, and `registry.ts` throws a clear "bump @parity/cdm-env" error on empty. - **Adding a network — the divergence guard is the gate.** A guard (`src/config.test.ts`) reads polkadot-app-deploy's bundled `environments.json` via its public `loadEnvironments()` and asserts every wired `CONFIGS` env's endpoints/network/gateway match upstream, AND that the default env's `getRegistryAddress(cdmEnvName)` is non-empty — so a network switch can't merge until the target is genuinely ready. To add/switch an env: populate `CONFIGS` (every field), confirm `@parity/cdm-env` ships a non-empty registry address for its `cdmEnvName` (`node --input-type=module -e "import { getRegistryAddress } from '@parity/cdm-env'; console.log(getRegistryAddress(''))"` — cdm-env is ESM-only, the `require()` form throws `ERR_PACKAGE_PATH_NOT_EXPORTED`), confirm its endpoints match `environments.json` (the guard enforces this — bump `@parity/polkadot-app-deploy` if they drift), flip `ACTIVE_TESTNET_ENV`, then `pnpm typecheck && pnpm test`. Prerequisites that land upstream BEFORE the CLI switch (not our PR): the env's CDM meta-registry contract + the playground-registry published to CDM (resolves by name `@w3s/playground-registry` — the `@w3s` there is the CDM package namespace, unrelated to the retired w3s env), its DotNS contracts (owned by polkadot-app-deploy's env catalog), and its registry address in `@parity/cdm-env`. Heads-up: an env's cdm-env `ipfsGatewayUrl` may be `""` and disagree with environments.json's `ipfs` — we source the gateway from environments.json on purpose, so the guard checks against that. - **Summit / w3s was RETIRED (July 2026 — part of the #465 bump; also the "retire summit" half of #466).** polkadot-app-deploy `0.13.x` dropped `summit` from its bundled `environments.json`, and `@parity/product-sdk-descriptors@0.8.0` dropped the `summit-*` descriptor subpaths — so the divergence guard failed on our still-wired summit `CONFIGS` entry. We removed the `SUMMIT` ChainConfig, the `summit` id from `ENV_IDS`, the `getNetworkLabel` case, and the `cdmEnvName: "w3s"` split (`config.test.ts` / `descriptors.test.ts` updated to match). `@parity/cdm-env@2.1.0` still exposes a `w3s` registry address, but nothing consumes it now. Do NOT re-add summit unless upstream restores it in `environments.json` AND ships summit descriptors again. Adding **devnet** (the other half of #466 — a new preset with its own registry + descriptors) is still open and was NOT done here. ### Deploy / Bulletin - **Bulletin storage chunks must NEVER sign with the phone session signer.** Chunk txs carry up to 2 MiB of callData; the phone path (`session.createTransaction`) forwards the full callData over the statement store, whose host-side request cap is 4 KiB (`DEFAULT_MAX_REQUEST_SIZE = 4096`, unchanged through host-papp 0.8.x; Android's own statement cap grew to 256 KiB in May 2026 but 2 MiB chunks exceed both), so every chunk dies client-side with "Mobile transaction signing rejected: message too big" and the phone never even shows a prompt. Since polkadot-app-deploy 0.8.x, passing `signer` routes STORAGE through it too (not just DotNS), so phone mode must also pass `storageSigner`/`storageSignerAddress` (the local BulletInAllowance slot key, which takes precedence for storage routing only). `src/utils/deploy/signerMode.ts::resolveStorageSignerOptions` is the single place that resolves it; both `runDeploy` and `runDecentralize` thread it into `runStorageDeploy`. polkadot-app-deploy (0.8.3 through 0.13.1) can auto-resolve the same slot key from the shared `dot-cli` allowance cache, but silently falls back to phone-signing the chunks when it misses, so don't rely on it. -- **Deploy delegates to `polkadot-app-deploy` for everything storage-related** — chunking, retries, pool accounts, nonce fallback, DAG-PB, DotNS commit-reveal. Don't reimplement. The one thing we own is `registry.publish()`. The contract takes an `Option
owner` parameter — when None, it falls back to `env::caller()`; when Some, that H160 is recorded as the app owner regardless of who signed. Phone mode passes None (caller IS the user). Dev mode with an active session passes the session's `productH160` so Alice can sign the tx while the user still appears in MyApps. The `publisher` field on `AppInfo` always stores `env::caller()`, so `is_authorized_to_republish` lets the original signer iterate without rewriting ownership. See `src/utils/deploy/playground.ts` and `src/utils/deploy/signerMode.ts::resolveSignerSetup`. +- **Deploy delegates to `polkadot-app-deploy` for everything storage-related** — chunking, retries, pool accounts, nonce fallback, DAG-PB, DotNS commit-reveal. Don't reimplement. The one thing we own is the registry publish, which since the July 2026 registry/identity-spine migration is TWO separate contract methods: `publish(...)` (user path) and `publishDev(...)` (dev path). The `owner` parameter is a plain `Address` — the ABI has no `Option` SolType; the ZERO address is the "none" sentinel (contract records `env::caller()` as owner), a non-zero H160 is recorded as owner regardless of who signed. The owner override is honored ONLY on `publishDev` — a user `publish` with a non-zero owner ≠ caller reverts `OwnerOverrideForbidden`. Phone mode passes zero (caller IS the user). Dev mode with an active session passes the session's `productH160` so the dev signer can sign the tx while the user still appears in MyApps. `publish` is gated FAIL-CLOSED by `require_revealed()` — the caller must be verified in the identity spine or it reverts `NotRevealed` (`playground.ts` appends a "Become a builder" hint to that revert). `publishDev` is exempt from the reveal gate but authorized by `env::caller()` matching one of the contract's compiled-in dev signers (`KNOWN_DEV_SIGNER_H160S` in `src/utils/contractManifest.ts`); `publish` still carries a trailing `is_dev_signer` boolean for ABI compatibility that the contract IGNORES — never rely on it. Routing lives in `signerMode.ts::isKnownDevPublishSigner`: it keys on the signer's H160, NOT on `--suri` provenance, so an arbitrary `--suri` key (e.g. the dedicated e2e deployer) publishes as a reveal-gated USER and must be revealed/seeded in the spine first — routing it through `publishDev` would revert `Unauthorized`. The `publisher` field on `AppInfo` always stores `env::caller()`, so `is_authorized_to_republish` (owner OR publisher, plus sudo/admin) lets the original signer iterate without rewriting ownership. See `src/utils/deploy/playground.ts` and `src/utils/deploy/signerMode.ts::resolveSignerSetup`. +- **The builder-identity gate reads the identity spine, not the registry.** `@w3s/playground-identity` is a second CDM contract in `cdm.json` (both `dependencies: "latest"` and a `contracts` ABI entry) resolved through the same live meta-registry pass as the registry (`getReadOnlyPlaygroundContracts`). `identityGate.ts` decides "revealed" via `spine.isVerified(productH160)` — the EXACT predicate the registry's `require_revealed()` publish gate delegates to on-chain, so the gate's verdict predicts scored-publish success precisely. The old registry exposed `get_root_account` inline; the migrated registry does NOT — don't point identity reads back at it. The CLI never calls `set_identity`/`faucet` (the reveal lives in playground-app's "Become a builder" flow; testnet seeding via `admin_set_identity` is an ops task). `cdm.json` semantics: contract ADDRESSES resolve live from the meta-registry at runtime, the ABI ships from the committed snapshot — the current snapshot was spliced from the contract build artifacts (playground-app `target/release/*.abi.json`) AHEAD of the new deploy; once the migrated contracts are published to CDM, refresh it with `cdm i` (which also fixes the placeholder `version`/`address` metadata on the identity entry). Until that deploy+publish happens, publishes against the LIVE (old) registry will fail — this branch must ship in lockstep with the contract deploy (handoff §5). - **Do NOT call `polkadot-app-deploy.deploy()` just to store a metadata JSON.** `deploy()` unconditionally runs a DotNS `register()` + `setContenthash()`, and for `domainName: null` invents a `test-domain-` label and registers THAT — the side-trip reverts cryptically. For metadata storage we submit `TransactionStorage.store` directly via PAPI using `calculateCid` from `@parity/product-sdk-bulletin`. The metadata `store` is signed with the product-scoped RFC-0010 Bulletin allowance account cached in `allowance-keys.json` (not Alice, not the product account). Asset Hub `registry.publish` is signed with the user's product account in phone mode, and with a dev signer in dev mode (claimed-owner H160 carries the user identity, per the bullet above). See `src/utils/deploy/playground.ts::publishToPlayground`. - **Dev mode must pass EXPLICIT auth options to `polkadot-app-deploy.deploy()` — never `{}`.** Since 0.8.x (the "#411 login UX"), `deploy()` called with no `mnemonic`, no `signer`, and no `suri` probes for a persisted SSO session file (`~/.polkadot-apps/dot-cli_SsoSessions.json` — the same `dot-cli` namespace `playground login` writes, because polkadot-app-deploy reuses `DOT_DAPP_ID = "dot-cli"`; since host-papp 0.8.7 our sessions land in `dot-cli_SsoSessionsV3.json`, and as of the terminal `^0.5.0` bump polkadot-app-deploy's host-papp dedupes onto 0.8.7 too, so its probe now DOES look for `SsoSessionsV3` and CAN find a logged-in user's session — the explicit options below are exactly what keeps this defused, so keep them regardless) and, when found, loads the SSO stack and phone-signs DotNS with the user's session — turning a "0 taps" dev deploy into 3-4 phone approvals for every logged-in user. Independently, an absent `storageSigner` makes it auto-read the user's cached BulletInAllowance slot key and burn their small phone-granted quota on chunk uploads, in every mode including `--suri`. `resolveSignerSetup` therefore pins `mnemonic: DEFAULT_MNEMONIC` for dev mode and `resolveStorageSignerOptions` pins `storageSigner` to the dev bare-root (dev) or the `--suri` key (suri) — the bare-root carries its own Bulletin authorization on paseo-next-v2, and polkadot-app-deploy's committed-signer wrapper falls back to the shared pool if it ever lapses. Tests in `signerMode.test.ts`, `run.test.ts`, and `decentralize/run.test.ts` pin the contract. - **The "dev signer" used in dev mode is polkadot-app-deploy's `DEFAULT_MNEMONIC` bare-root account, not Substrate's `//Alice`.** The bare-root SS58 (`5DfhGyQd…`) is what polkadot-app-deploy uses internally for its DEFAULT_MNEMONIC storage + DotNS signing, so the CLI's `createDevPublishSigner` derives from the same `(mnemonic, path="")` pair via `seedToAccount`. Storage, DotNS, and registry publish all sign as one identity. Substrate's `//Alice` (`5Grwva…`) is a DIFFERENT account — `createDevSigner("Alice")` from `@parity/product-sdk-tx` returns that one. Don't mix them; the `signerModeAlice.test.ts` snapshot guards against regression. @@ -76,7 +77,7 @@ These aren't self-evident from reading the code and have bitten us before. Treat ### CLI surface boundaries - **`src/utils/deploy/*` and `src/utils/build/*` must not import React or Ink.** They form the SDK surface RevX consumes from a WebContainer. TUI code lives in `src/commands/*/`. -- **`playground mod` runs signer-less.** `runModCommand` does not call `resolveSigner` — it uses `getReadOnlyRegistryContract(rawClient)` (origin = pallet-revive's keyless pallet account, `5EYCAe5ij…`, matching product-sdk's query fallback) for browse + metadata-uri lookup. The `--suri` flag is a deprecated no-op. Users browse + clone moddable apps without `playground login` / mapping their account. The signed `getRegistryContract(rawClient, signer)` is used only for `registry.publish.tx(...)` in `src/utils/deploy/playground.ts`. Don't drag a user signer back into `playground mod`. +- **`playground mod` runs signer-less.** `runModCommand` does not call `resolveSigner` — it uses `getReadOnlyPlaygroundContracts(rawClient)` (origin = pallet-revive's keyless pallet account, `5EYCAe5ij…`, matching product-sdk's query fallback), which resolves the registry AND the identity spine in ONE `fromLiveClient` pass, for browse + metadata-uri lookup + the identity gate. The `--suri` flag is a deprecated no-op. Users browse + clone moddable apps without `playground login` / mapping their account. The signed `getRegistryContract(rawClient, signer)` is used only for the registry publish tx in `src/utils/deploy/playground.ts`. Don't drag a user signer back into `playground mod`. - **`playground init` is a thin alias for `playground mod playground-template`.** `src/commands/init/index.ts` just calls the exported `runModCommand(TEMPLATE_DOMAIN)` with `TEMPLATE_DOMAIN = "playground-template"` — no logic of its own — so it inherits `mod`'s signer-less, GitHub-tarball-only behaviour automatically. Keep it a pure delegation: if `init` ever needs to diverge, change the constant, not the flow. It depends on a registry app published at domain `playground-template`; if that starter is renamed or unpublished, `init` breaks and the constant is the single place to update. - **`playground mod` is GitHub-tarball-only and must stay that way.** `src/utils/mod/source.ts` downloads from `codeload.github.com` (no auth, no `git`/`gh` for public repos) and extracts via `node:zlib` + the pure-JS `tar` package. Do NOT re-introduce `git clone` or `gh repo fork` — both re-add a hard tooling dep, and the fork path was specifically removed because GitHub caps you to one fork per source-repo per account. The interactive picker filters out non-moddable apps. The picker does NOT pre-probe each app's repo visibility (would burn the 60 req/hr anonymous GitHub quota); instead `runModCommand` lazy-probes the picked app once via `assertPublicGitHubRepo()` between picker dismount and `SetupScreen` mount. - **`playground` never invokes `gh`.** `playground deploy --moddable` reads an existing `origin`, validates it's a public GitHub URL via `HEAD https://github.com/{o}/{r}`, and records it in metadata. No auto-create path. Missing `origin`, private repos, and non-GitHub URLs all hard-fail with actionable messages from `src/utils/deploy/moddable.ts::resolveRepositoryUrl()`. We deliberately do NOT add an interactive `gh auth login` handoff — Ink owns stdout + raw-mode stdin and a `stdio: "inherit"` child would race `useInput` for keystrokes. @@ -92,7 +93,7 @@ These aren't self-evident from reading the code and have bitten us before. Treat - **`DeployLogParser.feed()` MUST NOT emit an event per log line.** It's called for every console line polkadot-app-deploy prints. We emit only for phase-banner matches and `[N/M]` chunk progress; everything else returns `null`. A catch-all `info` emit allocates ~200 bytes × thousands of lines and was a measurable contributor to chunk-upload memory pressure. - **The memory watchdog runs for EVERY command by default** (`runCliCommand`'s `watchdog` option defaults to true). It is the only guard that survives event-loop starvation: when a leaked polkadot-api subscription enters the microtask-flood state, signal handlers, `hardExit` timers, and `src/index.ts`'s final `process.exit()` all stop firing — the process looks finished but lingers invisibly and grows unbounded. We shipped exactly that in June 2026: `playground login` ran watchdog-less, and three zombie `playground` processes reached 40+ GB each, swapping the laptop to death. Do NOT opt a command out to save the worker thread — it costs one 1 Hz `memoryUsage()` sample. The related session-probe rule: every `createAdapter()` call site must destroy the adapter on EVERY path that doesn't transfer ownership to the caller (`connect()`'s existing-session and probe-throw paths leaked it; `src/utils/auth.connect.test.ts` pins the contract). - **`QueryResult` from `@parity/product-sdk-contracts@0.5+` is a discriminated union.** Narrow on `.success` before reading `.value`. On the failure branch `.value` is the runtime's dispatch-error payload (`unknown`). On the success branch `gasRequired` is non-optional. We apply this in `src/utils/contractManifest.ts::resolveLiveContractAddresses`, `src/commands/mod/AppBrowser.tsx`, and `src/commands/mod/SetupScreen.tsx`. -- **The product-sdk moved to a `Result`-based error API (`@parity/product-sdk@0.18.0`) — several calls that used to THROW now return a `Result` and NEVER reject.** The shape is `@parity/result`'s plain tagged union `{ ok: true; value } | { ok: false; error }` (NOT neverthrow's class API — narrow on `.ok`, read `.value`/`.error`). Migrated in #465: `submitAndWatch` (tx `0.3`), `Contract.tx()` + `ContractManager.fromLiveClient` (contracts `0.9`), and `checkAuthorization` (cloud-storage `0.8`). **The dangerous one is `submitAndWatch`**: pre-0.3 it threw on dispatch failure/timeout/rejection; now it returns `err(TxError)` and never rejects. Every CLI call site relied on the throw (deploy aborts, drip/fund error surfacing, the Invalid-Payment retry loop in `playground.ts`), so a naive bump SILENTLY swallows failures and tsc can't see it (the return was ignored). `src/utils/tx.ts::unwrapResult` re-throws the `err` channel and MUST wrap every `submitAndWatch`/batch call (`playground.ts`, `account/{drip,funding,allowance}.ts`) as well as `ContractManager.fromLiveClient` (`registry.ts`). Contract `.tx()` surfaces a REVERT on the `err` channel (`result.ok === false`), not as an inner `TxResult.ok=false` — `playground.ts`'s publish check relies on that. `isInvalidPaymentError` (`allowances/bulletin.ts`) now inspects `.message` + `.formatted` + `.dispatchError` + the `cause` chain, since `TxError`/`TxDispatchError` no longer carry the marker in `.message` alone. `registry.publishDev` was folded into `publish(…, is_dev_signer)` (contracts `0.9` dropped the separate method). +- **The product-sdk moved to a `Result`-based error API (`@parity/product-sdk@0.18.0`) — several calls that used to THROW now return a `Result` and NEVER reject.** The shape is `@parity/result`'s plain tagged union `{ ok: true; value } | { ok: false; error }` (NOT neverthrow's class API — narrow on `.ok`, read `.value`/`.error`). Migrated in #465: `submitAndWatch` (tx `0.3`), `Contract.tx()` + `ContractManager.fromLiveClient` (contracts `0.9`), and `checkAuthorization` (cloud-storage `0.8`). **The dangerous one is `submitAndWatch`**: pre-0.3 it threw on dispatch failure/timeout/rejection; now it returns `err(TxError)` and never rejects. Every CLI call site relied on the throw (deploy aborts, drip/fund error surfacing, the Invalid-Payment retry loop in `playground.ts`), so a naive bump SILENTLY swallows failures and tsc can't see it (the return was ignored). `src/utils/tx.ts::unwrapResult` re-throws the `err` channel and MUST wrap every `submitAndWatch`/batch call (`playground.ts`, `account/{drip,funding,allowance}.ts`) as well as `ContractManager.fromLiveClient` (`registry.ts`). Contract `.tx()` surfaces a REVERT on the `err` channel (`result.ok === false`), not as an inner `TxResult.ok=false` — `playground.ts`'s publish check relies on that. `isInvalidPaymentError` (`allowances/bulletin.ts`) now inspects `.message` + `.formatted` + `.dispatchError` + the `cause` chain, since `TxError`/`TxDispatchError` no longer carry the marker in `.message` alone. (History: contracts `0.9` briefly folded `registry.publishDev` into `publish(…, is_dev_signer)`; the July 2026 registry migration onto pvm-contract-sdk RESTORED the separate `publishDev` method and made the contract ignore the `is_dev_signer` calldata flag — see the Deploy / Bulletin invariant.) ## Repo conventions diff --git a/cdm.json b/cdm.json index 97926bbe..41b629a5 100644 --- a/cdm.json +++ b/cdm.json @@ -1,7 +1,8 @@ { "registry": "0xa5747e60ae27f93e92019e4021abfc4957050141", "dependencies": { - "@w3s/playground-registry": "latest" + "@w3s/playground-registry": "latest", + "@w3s/playground-identity": "latest" }, "contracts": { "@w3s/playground-registry": { @@ -15,176 +16,80 @@ }, { "type": "function", - "name": "spikeVerify", - "inputs": [ - { - "name": "signature", - "type": "uint8[]" - }, - { - "name": "message", - "type": "uint8[]" - }, - { - "name": "public_key", - "type": "bytes32" - } - ], + "name": "getAppCount", + "inputs": [], "outputs": [ { "name": "", - "type": "bool" + "type": "uint32" } ], "stateMutability": "view" }, { "type": "function", - "name": "publish", + "name": "getDomainAt", "inputs": [ { - "name": "domain", - "type": "string" - }, - { - "name": "metadata_uri", - "type": "string" - }, - { - "name": "visibility", - "type": "uint8" - }, - { - "name": "owner", - "type": "tuple", - "components": [ - { - "name": "isSome", - "type": "bool" - }, - { - "name": "value", - "type": "address" - } - ] - }, + "name": "index", + "type": "uint32" + } + ], + "outputs": [ { - "name": "modded_from", + "name": "", "type": "string" - }, - { - "name": "is_moddable", - "type": "bool" - }, - { - "name": "is_dev_signer", - "type": "bool" } ], - "outputs": [], - "stateMutability": "nonpayable" + "stateMutability": "view" }, { "type": "function", - "name": "publishDev", + "name": "getMetadataUri", "inputs": [ { "name": "domain", "type": "string" - }, - { - "name": "metadata_uri", - "type": "string" - }, - { - "name": "visibility", - "type": "uint8" - }, - { - "name": "owner", - "type": "tuple", - "components": [ - { - "name": "isSome", - "type": "bool" - }, - { - "name": "value", - "type": "address" - } - ] - }, - { - "name": "modded_from", - "type": "string" - }, - { - "name": "is_moddable", - "type": "bool" } ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "unpublish", - "inputs": [ + "outputs": [ { - "name": "domain", + "name": "", "type": "string" } ], - "outputs": [], - "stateMutability": "nonpayable" + "stateMutability": "view" }, { "type": "function", - "name": "star", + "name": "getOwner", "inputs": [ { "name": "domain", "type": "string" } ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "getAppCount", - "inputs": [], "outputs": [ { "name": "", - "type": "uint32" + "type": "address" } ], "stateMutability": "view" }, { "type": "function", - "name": "getDomainAt", + "name": "getVisibility", "inputs": [ { - "name": "index", - "type": "uint32" + "name": "domain", + "type": "string" } ], "outputs": [ { "name": "", - "type": "tuple", - "components": [ - { - "name": "isSome", - "type": "bool" - }, - { - "name": "value", - "type": "string" - } - ] + "type": "uint8" } ], "stateMutability": "view" @@ -222,17 +127,7 @@ "outputs": [ { "name": "", - "type": "tuple", - "components": [ - { - "name": "isSome", - "type": "bool" - }, - { - "name": "value", - "type": "string" - } - ] + "type": "string" } ], "stateMutability": "view" @@ -251,34 +146,27 @@ }, { "type": "function", - "name": "addAdmin", + "name": "isAdmin", "inputs": [ { - "name": "admin", + "name": "addr", "type": "address" } ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "removeAdmin", - "inputs": [ + "outputs": [ { - "name": "admin", - "type": "address" + "name": "", + "type": "bool" } ], - "outputs": [], - "stateMutability": "nonpayable" + "stateMutability": "view" }, { "type": "function", - "name": "isAdmin", + "name": "isBlacklisted", "inputs": [ { - "name": "addr", + "name": "account", "type": "address" } ], @@ -292,92 +180,102 @@ }, { "type": "function", - "name": "adminSetDeployAwardCount", + "name": "getPoints", "inputs": [ { "name": "account", "type": "address" - }, + } + ], + "outputs": [ { - "name": "count", - "type": "uint32" + "name": "", + "type": "uint128" } ], - "outputs": [], - "stateMutability": "nonpayable" + "stateMutability": "view" }, { "type": "function", - "name": "adminSetAppSocialCounts", + "name": "getApps", "inputs": [ { - "name": "domain", - "type": "string" - }, - { - "name": "star_count", + "name": "start", "type": "uint32" }, { - "name": "mod_count", + "name": "count", "type": "uint32" } ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "setBlacklisted", - "inputs": [ - { - "name": "accounts", - "type": "address[]" - }, + "outputs": [ { - "name": "value", - "type": "bool" + "name": "", + "type": "tuple", + "components": [ + { + "name": "total", + "type": "uint32" + }, + { + "name": "scanned", + "type": "uint32" + }, + { + "name": "entries", + "type": "tuple[]", + "components": [ + { + "name": "index", + "type": "uint32" + }, + { + "name": "domain", + "type": "string" + }, + { + "name": "metadata_uri", + "type": "string" + }, + { + "name": "owner", + "type": "address" + }, + { + "name": "visibility", + "type": "uint8" + }, + { + "name": "publisher", + "type": "address" + } + ] + } + ] } ], - "outputs": [], - "stateMutability": "nonpayable" + "stateMutability": "view" }, { "type": "function", - "name": "isBlacklisted", + "name": "getStarCount", "inputs": [ { - "name": "account", - "type": "address" + "name": "domain", + "type": "string" } ], "outputs": [ { "name": "", - "type": "bool" + "type": "uint32" } ], "stateMutability": "view" }, { "type": "function", - "name": "setVisibility", - "inputs": [ - { - "name": "domain", - "type": "string" - }, - { - "name": "visibility", - "type": "uint8" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "getVisibility", + "name": "getModCount", "inputs": [ { "name": "domain", @@ -387,39 +285,62 @@ "outputs": [ { "name": "", - "type": "uint8" + "type": "uint32" } ], "stateMutability": "view" }, { "type": "function", - "name": "pin", - "inputs": [ + "name": "getLineageCount", + "inputs": [], + "outputs": [ { - "name": "domain", - "type": "string" + "name": "", + "type": "uint32" } ], - "outputs": [], - "stateMutability": "nonpayable" + "stateMutability": "view" }, { "type": "function", - "name": "unpin", + "name": "getLineage", "inputs": [ { - "name": "domain", - "type": "string" + "name": "start", + "type": "uint32" + }, + { + "name": "count", + "type": "uint32" } ], - "outputs": [], - "stateMutability": "nonpayable" + "outputs": [ + { + "name": "", + "type": "tuple[]", + "components": [ + { + "name": "child", + "type": "string" + }, + { + "name": "source", + "type": "string" + } + ] + } + ], + "stateMutability": "view" }, { "type": "function", - "name": "isPinned", + "name": "hasStarred", "inputs": [ + { + "name": "voter", + "type": "address" + }, { "name": "domain", "type": "string" @@ -435,17 +356,22 @@ }, { "type": "function", - "name": "getPinnedApps", - "inputs": [], + "name": "getAppData", + "inputs": [ + { + "name": "domains", + "type": "string[]" + }, + { + "name": "voter", + "type": "address" + } + ], "outputs": [ { "name": "", "type": "tuple[]", "components": [ - { - "name": "index", - "type": "uint32" - }, { "name": "domain", "type": "string" @@ -465,6 +391,18 @@ { "name": "publisher", "type": "address" + }, + { + "name": "star_count", + "type": "uint32" + }, + { + "name": "mod_count", + "type": "uint32" + }, + { + "name": "has_starred", + "type": "bool" } ] } @@ -473,15 +411,11 @@ }, { "type": "function", - "name": "getApps", + "name": "getPointBreakdown", "inputs": [ { - "name": "start", - "type": "uint32" - }, - { - "name": "count", - "type": "uint32" + "name": "account", + "type": "address" } ], "outputs": [ @@ -490,42 +424,20 @@ "type": "tuple", "components": [ { - "name": "total", - "type": "uint32" + "name": "launch_points", + "type": "uint128" }, { - "name": "scanned", - "type": "uint32" + "name": "mod_points", + "type": "uint128" }, { - "name": "entries", - "type": "tuple[]", - "components": [ - { - "name": "index", - "type": "uint32" - }, - { - "name": "domain", - "type": "string" - }, - { - "name": "metadata_uri", - "type": "string" - }, - { - "name": "owner", - "type": "address" - }, - { - "name": "visibility", - "type": "uint8" - }, - { - "name": "publisher", - "type": "address" - } - ] + "name": "star_points", + "type": "uint128" + }, + { + "name": "total", + "type": "uint128" } ] } @@ -534,120 +446,53 @@ }, { "type": "function", - "name": "getTopStarred", + "name": "isPinned", "inputs": [ { - "name": "start", - "type": "uint32" - }, - { - "name": "count", - "type": "uint32" + "name": "domain", + "type": "string" } ], "outputs": [ { "name": "", - "type": "tuple", - "components": [ - { - "name": "total", - "type": "uint32" - }, - { - "name": "scanned", - "type": "uint32" - }, - { - "name": "entries", - "type": "tuple[]", - "components": [ - { - "name": "index", - "type": "uint32" - }, - { - "name": "domain", - "type": "string" - }, - { - "name": "metadata_uri", - "type": "string" - }, - { - "name": "owner", - "type": "address" - }, - { - "name": "visibility", - "type": "uint8" - }, - { - "name": "publisher", - "type": "address" - } - ] - } - ] + "type": "bool" } ], "stateMutability": "view" }, { "type": "function", - "name": "getTopModded", - "inputs": [ - { - "name": "start", - "type": "uint32" - }, - { - "name": "count", - "type": "uint32" - } - ], + "name": "getPinnedApps", + "inputs": [], "outputs": [ { "name": "", - "type": "tuple", + "type": "tuple[]", "components": [ { - "name": "total", + "name": "index", "type": "uint32" }, { - "name": "scanned", - "type": "uint32" + "name": "domain", + "type": "string" }, { - "name": "entries", - "type": "tuple[]", - "components": [ - { - "name": "index", - "type": "uint32" - }, - { - "name": "domain", - "type": "string" - }, - { - "name": "metadata_uri", - "type": "string" - }, - { - "name": "owner", - "type": "address" - }, - { - "name": "visibility", - "type": "uint8" - }, - { - "name": "publisher", - "type": "address" - } - ] + "name": "metadata_uri", + "type": "string" + }, + { + "name": "owner", + "type": "address" + }, + { + "name": "visibility", + "type": "uint8" + }, + { + "name": "publisher", + "type": "address" } ] } @@ -656,7 +501,7 @@ }, { "type": "function", - "name": "getLineageCount", + "name": "getBuilderCount", "inputs": [], "outputs": [ { @@ -668,7 +513,7 @@ }, { "type": "function", - "name": "getLineage", + "name": "getBuilders", "inputs": [ { "name": "start", @@ -685,12 +530,12 @@ "type": "tuple[]", "components": [ { - "name": "child", - "type": "string" + "name": "account", + "type": "address" }, { - "name": "source", - "type": "string" + "name": "score", + "type": "uint128" } ] } @@ -699,258 +544,243 @@ }, { "type": "function", - "name": "getMetadataUri", + "name": "pin", "inputs": [ { "name": "domain", "type": "string" } ], - "outputs": [ + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "unpin", + "inputs": [ { - "name": "", - "type": "tuple", - "components": [ - { - "name": "isSome", - "type": "bool" - }, - { - "name": "value", - "type": "string" - } - ] + "name": "domain", + "type": "string" } ], - "stateMutability": "view" + "outputs": [], + "stateMutability": "nonpayable" }, { "type": "function", - "name": "getAppData", + "name": "star", "inputs": [ { - "name": "domains", - "type": "string[]" + "name": "domain", + "type": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "publish", + "inputs": [ + { + "name": "domain", + "type": "string" }, { - "name": "voter", + "name": "metadata_uri", + "type": "string" + }, + { + "name": "visibility", + "type": "uint8" + }, + { + "name": "owner", "type": "address" - } - ], - "outputs": [ + }, { - "name": "", - "type": "tuple[]", - "components": [ - { - "name": "domain", - "type": "string" - }, - { - "name": "metadata_uri", - "type": "tuple", - "components": [ - { - "name": "isSome", - "type": "bool" - }, - { - "name": "value", - "type": "string" - } - ] - }, - { - "name": "owner", - "type": "address" - }, - { - "name": "visibility", - "type": "uint8" - }, - { - "name": "publisher", - "type": "address" - }, - { - "name": "star_count", - "type": "uint32" - }, - { - "name": "mod_count", - "type": "uint32" - }, - { - "name": "has_starred", - "type": "bool" - } - ] + "name": "modded_from", + "type": "string" + }, + { + "name": "is_moddable", + "type": "bool" + }, + { + "name": "is_dev_signer", + "type": "bool" } ], - "stateMutability": "view" + "outputs": [], + "stateMutability": "nonpayable" }, { "type": "function", - "name": "getOwner", + "name": "publishDev", "inputs": [ { "name": "domain", "type": "string" - } - ], - "outputs": [ + }, { - "name": "", + "name": "metadata_uri", + "type": "string" + }, + { + "name": "visibility", + "type": "uint8" + }, + { + "name": "owner", "type": "address" + }, + { + "name": "modded_from", + "type": "string" + }, + { + "name": "is_moddable", + "type": "bool" } ], - "stateMutability": "view" + "outputs": [], + "stateMutability": "nonpayable" }, { "type": "function", - "name": "getPoints", + "name": "setVisibility", "inputs": [ { - "name": "account", - "type": "address" + "name": "domain", + "type": "string" + }, + { + "name": "visibility", + "type": "uint8" } ], - "outputs": [ + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "unpublish", + "inputs": [ { - "name": "", - "type": "uint128" + "name": "domain", + "type": "string" } ], - "stateMutability": "view" + "outputs": [], + "stateMutability": "nonpayable" }, { "type": "function", - "name": "getTopBuilders", + "name": "addAdmin", "inputs": [ { - "name": "start", - "type": "uint32" - }, - { - "name": "count", - "type": "uint32" + "name": "admin", + "type": "address" } ], - "outputs": [ + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "removeAdmin", + "inputs": [ { - "name": "", - "type": "tuple[]", - "components": [ - { - "name": "account", - "type": "address" - }, - { - "name": "score", - "type": "uint128" - } - ] + "name": "admin", + "type": "address" } ], - "stateMutability": "view" + "outputs": [], + "stateMutability": "nonpayable" }, { "type": "function", - "name": "getModCount", + "name": "adminSetDeployAwardCount", "inputs": [ { - "name": "domain", - "type": "string" - } - ], - "outputs": [ + "name": "account", + "type": "address" + }, { - "name": "", + "name": "count", "type": "uint32" } ], - "stateMutability": "view" + "outputs": [], + "stateMutability": "nonpayable" }, { "type": "function", - "name": "getStarCount", + "name": "adminSetAppSocialCounts", "inputs": [ { "name": "domain", "type": "string" - } - ], - "outputs": [ + }, { - "name": "", + "name": "star_count", + "type": "uint32" + }, + { + "name": "mod_count", "type": "uint32" } ], - "stateMutability": "view" + "outputs": [], + "stateMutability": "nonpayable" }, { "type": "function", - "name": "hasStarred", + "name": "setBlacklisted", "inputs": [ { - "name": "voter", - "type": "address" + "name": "accounts", + "type": "address[]" }, { - "name": "domain", - "type": "string" - } - ], - "outputs": [ - { - "name": "", + "name": "value", "type": "bool" } ], - "stateMutability": "view" + "outputs": [], + "stateMutability": "nonpayable" }, { "type": "function", - "name": "getPointBreakdown", + "name": "setFrozen", "inputs": [ { - "name": "account", - "type": "address" + "name": "value", + "type": "bool" } ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isFrozen", + "inputs": [], "outputs": [ { "name": "", - "type": "tuple", - "components": [ - { - "name": "launch_points", - "type": "uint128" - }, - { - "name": "mod_points", - "type": "uint128" - }, - { - "name": "star_points", - "type": "uint128" - }, - { - "name": "total", - "type": "uint128" - } - ] + "type": "bool" } ], "stateMutability": "view" }, { "type": "function", - "name": "setFrozen", + "name": "setVerifier", "inputs": [ { - "name": "value", - "type": "bool" + "name": "verifier", + "type": "address" } ], "outputs": [], @@ -958,12 +788,12 @@ }, { "type": "function", - "name": "isFrozen", + "name": "getVerifier", "inputs": [], "outputs": [ { "name": "", - "type": "bool" + "type": "address" } ], "stateMutability": "view" @@ -1121,25 +951,109 @@ "stateMutability": "nonpayable" }, { - "type": "function", - "name": "importIdentities", - "inputs": [ - { - "name": "entries", - "type": "tuple[]", - "components": [ - { - "name": "account", - "type": "address" - }, - { - "name": "root_pubkey", - "type": "bytes32" - } - ] - } - ], - "outputs": [], + "type": "error", + "name": "NotRevealed", + "inputs": [] + }, + { + "type": "error", + "name": "CannotPinPrivateApp", + "inputs": [] + }, + { + "type": "error", + "name": "AlreadyPinned", + "inputs": [] + }, + { + "type": "error", + "name": "NotPinned", + "inputs": [] + }, + { + "type": "error", + "name": "DeployAwardCountTooHigh", + "inputs": [] + }, + { + "type": "error", + "name": "Unauthorized", + "inputs": [] + }, + { + "type": "error", + "name": "Frozen", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidVisibility", + "inputs": [] + }, + { + "type": "error", + "name": "EmptyMetadataUri", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidModdedFrom", + "inputs": [] + }, + { + "type": "error", + "name": "OwnerOverrideForbidden", + "inputs": [] + }, + { + "type": "error", + "name": "AppNotFound", + "inputs": [] + }, + { + "type": "error", + "name": "SelfStarForbidden", + "inputs": [] + }, + { + "type": "error", + "name": "AlreadyStarred", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidCalldata", + "inputs": [] + }, + { + "type": "error", + "name": "CalldataTooLarge", + "inputs": [] + }, + { + "type": "error", + "name": "NoSelector", + "inputs": [] + }, + { + "type": "error", + "name": "UnknownSelector", + "inputs": [] + }, + { + "type": "error", + "name": "NonPayableValueReceived", + "inputs": [] + } + ] + }, + "@w3s/playground-identity": { + "version": 0, + "address": "0x0000000000000000000000000000000000000000", + "abi": [ + { + "type": "constructor", + "inputs": [], "stateMutability": "nonpayable" }, { @@ -1160,17 +1074,44 @@ }, { "type": "function", - "name": "claimAnonymousBonus", + "name": "clearIdentity", "inputs": [], "outputs": [], "stateMutability": "nonpayable" }, { "type": "function", - "name": "clearIdentity", - "inputs": [], - "outputs": [], - "stateMutability": "nonpayable" + "name": "isVerified", + "inputs": [ + { + "name": "account", + "type": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "rootOf", + "inputs": [ + { + "name": "account", + "type": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view" }, { "type": "function", @@ -1206,6 +1147,13 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "faucet", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "adminSetIdentity", @@ -1236,13 +1184,112 @@ }, { "type": "function", - "name": "faucet", - "inputs": [], + "name": "addAdmin", + "inputs": [ + { + "name": "admin", + "type": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "removeAdmin", + "inputs": [ + { + "name": "admin", + "type": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setFrozen", + "inputs": [ + { + "name": "value", + "type": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "importIdentities", + "inputs": [ + { + "name": "accounts", + "type": "address[]" + }, + { + "name": "roots", + "type": "bytes32[]" + } + ], "outputs": [], "stateMutability": "nonpayable" + }, + { + "type": "error", + "name": "IdentitySigLen", + "inputs": [] + }, + { + "type": "error", + "name": "IdentityRootZero", + "inputs": [] + }, + { + "type": "error", + "name": "IdentitySigInvalid", + "inputs": [] + }, + { + "type": "error", + "name": "IdentityRootTaken", + "inputs": [] + }, + { + "type": "error", + "name": "Unauthorized", + "inputs": [] + }, + { + "type": "error", + "name": "Frozen", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidCalldata", + "inputs": [] + }, + { + "type": "error", + "name": "CalldataTooLarge", + "inputs": [] + }, + { + "type": "error", + "name": "NoSelector", + "inputs": [] + }, + { + "type": "error", + "name": "UnknownSelector", + "inputs": [] + }, + { + "type": "error", + "name": "NonPayableValueReceived", + "inputs": [] } - ], - "metadataCid": "bafk2bzaceciehmxltebki5zxj4p7i476dnbn4yxeuisja76ko4shbjd6op5g2" + ] } } } diff --git a/docs/e2e-bootstrap.md b/docs/e2e-bootstrap.md index e74b7564..4e036f1e 100644 --- a/docs/e2e-bootstrap.md +++ b/docs/e2e-bootstrap.md @@ -23,6 +23,18 @@ SIGNER must hold enough PAS to cover `registry.publish()` fees. the bootstrap tool (`tools/register-e2e-fixtures.ts`) does the same before registering fixtures. Minimum held: 500 PAS. Top-up amount: 1000 PAS. Cost per publish: ~0.1 PAS. +**Identity-spine reveal (required since the registry/identity-spine migration).** +`registry.publish()` is gated fail-closed by `require_revealed()`: the caller's H160 must be +verified in the `@w3s/playground-identity` spine or every publish reverts `NotRevealed`. +SIGNER is neither of the registry's compiled-in dev signers (so it cannot use `publishDev`) +and, as a bare local key with no phone session, cannot use the playground-app "Become a +builder" flow — it must be seeded ONCE per spine deployment by a spine admin/sudo: +`admin_set_identity(, )` (or `import_identities`). SIGNER's +H160 is printed by `tools/register-e2e-fixtures.ts` and derivable from the mnemonic above. +Until this is done, `ensureTemplateRegistered`, `tools/register-e2e-fixtures.ts`, and every +publish e2e leg fail at registry publish with `NotRevealed`. Re-seed after any spine +redeploy/wipe (same class of event as "Registry contract redeployed" below). + ### Permanent fixture domains Two categories. All owned by SIGNER after bootstrap. diff --git a/e2e/cli/deploy.test.ts b/e2e/cli/deploy.test.ts index cf3df967..019973c8 100644 --- a/e2e/cli/deploy.test.ts +++ b/e2e/cli/deploy.test.ts @@ -210,17 +210,18 @@ describe("dot deploy --playground — full pipeline (requires Paseo + IPFS)", () } }); - // The dev-mode + active-session flow (Alice signs the publish tx but the - // session's H160 is passed as the `owner` arg via Option
) is the - // headline behaviour of the fully-dev-deploy change. We can't e2e-test + // The dev-mode + active-session flow (the dev signer signs the publish tx + // but the session's H160 is passed as the plain-`Address` `owner` arg — + // zero address = none; the override is honored only on `publishDev`) is + // the headline behaviour of the fully-dev-deploy change. We can't e2e-test // it here because spinning up a real Polkadot-app SSO session against // Paseo would require running the mobile app or replicating its session- // pairing handshake against the live SSO endpoint. The unit-level coverage // lives in run.test.ts ("dev mode with playground: ZERO planned approvals // AND user H160 is claimed as owner") which asserts the publishToPlayground // call is dispatched with the right `claimedOwnerH160`, and in - // playground.test.ts which asserts the registry.publish.tx receives the - // right Option
tuple. Skip-gated contract tests in playground-app + // playground.test.ts which asserts registry.publishDev.tx receives the + // claimed owner as a plain address. Skip-gated contract tests in playground-app // (tests/contract/registry.test.ts "publish with owner=Some(...)") will // close the on-chain end of this once the local revive-dev-node wiring // lands. See docs/superpowers/specs/2026-05-20-fully-dev-deploy-design.md. diff --git a/e2e/cli/fixtures/registry.ts b/e2e/cli/fixtures/registry.ts index cb0a1fbd..bd1b7aed 100644 --- a/e2e/cli/fixtures/registry.ts +++ b/e2e/cli/fixtures/registry.ts @@ -20,7 +20,11 @@ */ import { getTestClient } from "../helpers/chain.js"; -import { getReadOnlyRegistryContract } from "../../../src/utils/registry.js"; +import { + getReadOnlyRegistryContract, + queryMetadataUri, + type MetadataUriRegistry, +} from "../../../src/utils/registry.js"; import { resolveSigner } from "../../../src/utils/signer.js"; import { publishToPlayground } from "../../../src/utils/deploy/playground.js"; import { SIGNER } from "./accounts.js"; @@ -39,6 +43,8 @@ async function getRegistry(): Promise { if (!registryPromise) { registryPromise = (async () => { const client = await getTestClient(); + // Registry-only resolution: these fixtures never touch the + // identity spine, so don't couple them to its availability. return getReadOnlyRegistryContract(client.raw.assetHub); })().catch((err) => { // Reset so the next call can retry instead of replaying the error @@ -51,24 +57,21 @@ async function getRegistry(): Promise { /** * Query the registry for an app entry by domain. - * Returns null if not found. - * - * `getMetadataUri` returns an `Option` shaped as `{ isSome, value }` — - * always a truthy object regardless of registration. The `isSome` flag is the - * real discriminator; check it explicitly. (See cdm.json's getMetadataUri ABI.) + * Returns null if not found (queryMetadataUri owns the ""-sentinel decode; + * dry-run failures collapse to null here — fixtures treat them as "absent"). */ export async function getApp(domain: string): Promise { try { const registry = await getRegistry(); - const res = await registry.getMetadataUri.query(domain); - if (!res.success) return null; - const tuple = res.value as { isSome?: boolean; value?: string } | undefined; - if (!tuple?.isSome) return null; - return { + // Cast = the codegen seam: this tree typechecks without the gitignored + // .cdm augmentation, where the handle is the un-augmented + // Contract (see MetadataUriRegistry's doc comment). + const metadataUri = await queryMetadataUri( + registry as unknown as MetadataUriRegistry, domain, - owner: "", - metadataUri: String(tuple.value ?? ""), - }; + ); + if (!metadataUri) return null; + return { domain, owner: "", metadataUri }; } catch { return null; } diff --git a/src/commands/mod/SetupScreen.tsx b/src/commands/mod/SetupScreen.tsx index e62eb2fd..2eaf0ac7 100644 --- a/src/commands/mod/SetupScreen.tsx +++ b/src/commands/mod/SetupScreen.tsx @@ -40,6 +40,7 @@ import { Select } from "../../utils/ui/theme/Select.js"; import { VERSION_LABEL } from "../../utils/version.js"; import { getNetworkLabel } from "../../config.js"; import { fetchBulletinJson, getBulletinGateway } from "../../utils/bulletinGateway.js"; +import { queryMetadataUri } from "../../utils/registry.js"; interface AppMetadata { name?: string; @@ -94,13 +95,8 @@ export function SetupScreen({ domain, metadata: initial, registry, targetDir, on return; } log(`querying registry for ${domain}...`); - const metaRes = await registry.getMetadataUri.query(domain); - if (!metaRes.success) { - throw new Error( - `Registry lookup for "${domain}" failed at dry-run (chain rejected the call).`, - ); - } - const cid = metaRes.value.isSome ? metaRes.value.value : null; + // queryMetadataUri owns the ""-sentinel decode + dry-run error. + const cid = await queryMetadataUri(registry, domain); if (!cid) throw new Error(`App "${domain}" not found in registry`); log(`fetching metadata from IPFS (${cid.slice(0, 16)}...)...`); diff --git a/src/commands/mod/index.ts b/src/commands/mod/index.ts index 10e4a77c..91be14b2 100644 --- a/src/commands/mod/index.ts +++ b/src/commands/mod/index.ts @@ -19,8 +19,8 @@ import { Command } from "commander"; import { existsSync } from "node:fs"; import { withSpan } from "../../telemetry.js"; import { getConnection, destroyConnection } from "../../utils/connection.js"; -import { getReadOnlyRegistryContract } from "../../utils/registry.js"; -import type { IdentityRegistry } from "../../utils/identity/identityGate.js"; +import { getReadOnlyPlaygroundContracts, queryMetadataUri } from "../../utils/registry.js"; +import type { IdentitySpine } from "../../utils/identity/identityGate.js"; import { enforceIdentityGate } from "../shared/gateOrNotice.js"; import { AppBrowser, type AppEntry } from "./AppBrowser.js"; import { SetupScreen } from "./SetupScreen.js"; @@ -45,7 +45,7 @@ export const modCommand = new Command("mod") .argument("[domain]", "App domain (interactive picker if omitted)") // --suri is retained as a no-op for backcompat. `playground mod` is fully // read-only on the chain side now (browse + metadata lookups go through - // getReadOnlyRegistryContract with the keyless pallet-revive dry-run + // getReadOnlyPlaygroundContracts with the keyless pallet-revive dry-run // origin), so there's no signer to feed. .option("--suri ", "(deprecated, no-op) Signer secret URI") .action(async (rawDomain: string | undefined, _opts: { suri?: string }) => @@ -57,17 +57,19 @@ export async function runModCommand(rawDomain: string | undefined): Promise getConnection(), ); - const registry = await withSpan("cli.mod.registry", "load registry contract", () => - getReadOnlyRegistryContract(client.raw.assetHub), + const { registry, identity } = await withSpan( + "cli.mod.registry", + "load playground contracts", + () => getReadOnlyPlaygroundContracts(client.raw.assetHub), ); // Builder-identity gate: modding is reserved for revealed builders who // joined the competition. This also gates `playground init`, which - // delegates here. Reuse the registry we just resolved so the gate + // delegates here. Reuse the spine handle we just resolved so the gate // doesn't re-resolve it. Blocked is a soft outcome (yellow box, exit 0). - if ( - await enforceIdentityGate(client.raw.assetHub, registry as unknown as IdentityRegistry) - ) { + // The cast is the codegen seam (CI typechecks without the gitignored + // .cdm augmentation) — see IdentitySpine's doc comment. + if (await enforceIdentityGate(client.raw.assetHub, identity as unknown as IdentitySpine)) { process.exitCode = 0; return; } @@ -214,13 +216,8 @@ async function resolveTargetDir(args: { domain: string }): Promise { - const metaRes = await registry.getMetadataUri.query(domain); - if (!metaRes.success) { - throw new Error( - `Registry lookup for "${domain}" failed at dry-run (chain rejected the call)`, - ); - } - const cid = metaRes.value.isSome ? metaRes.value.value : null; + // queryMetadataUri owns the ""-sentinel decode and dry-run error shaping. + const cid = await queryMetadataUri(registry, domain); if (!cid) throw new Error(`App "${domain}" not found in registry`); return await fetchBulletinJson(cid, getBulletinGateway()); } diff --git a/src/commands/shared/gateOrNotice.test.ts b/src/commands/shared/gateOrNotice.test.ts index 60f8a580..91e821ac 100644 --- a/src/commands/shared/gateOrNotice.test.ts +++ b/src/commands/shared/gateOrNotice.test.ts @@ -70,12 +70,12 @@ describe("enforceIdentityGate", () => { }, ); - it("forwards a pre-resolved registry to the gate (so mod doesn't re-resolve)", async () => { + it("forwards a pre-resolved identity-spine handle to the gate (so mod doesn't re-resolve)", async () => { checkIdentityGateMock.mockResolvedValue({ status: "revealed", productH160: H160 }); - const registry = { getRootAccount: { query: vi.fn() } }; + const identity = { isVerified: { query: vi.fn() } }; - await enforceIdentityGate(RAW, registry as any); + await enforceIdentityGate(RAW, identity as any); - expect(checkIdentityGateMock).toHaveBeenCalledWith(RAW, { registry }); + expect(checkIdentityGateMock).toHaveBeenCalledWith(RAW, { identity }); }); }); diff --git a/src/commands/shared/gateOrNotice.ts b/src/commands/shared/gateOrNotice.ts index 8303ac9f..d48eaadc 100644 --- a/src/commands/shared/gateOrNotice.ts +++ b/src/commands/shared/gateOrNotice.ts @@ -15,7 +15,7 @@ import type { PolkadotClient } from "polkadot-api"; import { withSpan } from "../../telemetry.js"; -import { checkIdentityGate, type IdentityRegistry } from "../../utils/identity/identityGate.js"; +import { checkIdentityGate, type IdentitySpine } from "../../utils/identity/identityGate.js"; import { renderIdentityGateNotice } from "./IdentityGateNotice.js"; /** @@ -28,10 +28,10 @@ import { renderIdentityGateNotice } from "./IdentityGateNotice.js"; */ export async function enforceIdentityGate( rawAssetHubClient: PolkadotClient, - registry?: IdentityRegistry, + identity?: IdentitySpine, ): Promise { const result = await withSpan("cli.identity-gate", "check builder identity", () => - checkIdentityGate(rawAssetHubClient, { registry }), + checkIdentityGate(rawAssetHubClient, { identity }), ); if (result.status === "revealed") return false; await renderIdentityGateNotice(result.status); diff --git a/src/utils/contractManifest.ts b/src/utils/contractManifest.ts index 2832024d..be372d7d 100644 --- a/src/utils/contractManifest.ts +++ b/src/utils/contractManifest.ts @@ -18,13 +18,57 @@ * * Live contract-address resolution now lives natively in * `@parity/product-sdk-contracts` (`ContractManager.fromLiveClient`), consumed - * from `registry.ts`. This module only owns the registry contract NAME and the + * from `registry.ts`. This module only owns the playground contract NAMEs and the * helpers that hide the known `ReviveApi_trace_call` dry-run noise on Paseo * Asset Hub. */ export const PLAYGROUND_REGISTRY_CONTRACT = "@w3s/playground-registry"; +/** + * The identity spine: a separate global contract the registry delegates its + * "is this caller a revealed builder?" checks to (via its configured + * `verifier`). The CLI reads it for the builder-identity gate (`is_verified` + * only); the reveal itself (`set_identity`) happens in the playground-app + * "Become a builder" flow, never here. + */ +export const PLAYGROUND_IDENTITY_CONTRACT = "@w3s/playground-identity"; + +/** + * The zero H160 — the registry ABI's "none" sentinel for `Address` parameters + * (no Option SolType): a zero `owner` on publish/publishDev means "record + * `env::caller()`", and a zero result from owner reads means "unset". + * Single shared constant so the sentinel can't drift per copy (a 19- or + * 21-byte typo in a hex literal still satisfies `0x${string}`, so tsc can't + * catch divergence). + */ +export const ZERO_H160 = "0x0000000000000000000000000000000000000000" as const; + +/** + * H160s the registry contract hardcodes as "known dev signers" + * (`DEV_SIGNER_H160` / `BUILDER_DEV_SIGNER_H160` in the contract source). + * ONLY these callers may use `publishDev(...)` — the dev path is authorized + * by `env::caller()`, not by calldata — and they are the only accounts that + * can publish without being revealed in the identity spine. + * + * These are part of the deployed contract's identity (compiled-in constants, + * env-independent), which is why they live here next to the contract names + * rather than in `config.ts`'s per-env tables. If the contract rotates or + * adds a dev signer, update this set in the same PR that bumps `cdm.json`. + */ +export const KNOWN_DEV_SIGNER_H160S: ReadonlySet = new Set( + [ + // polkadot-app-deploy DEFAULT_MNEMONIC bare-root — the CLI's dev-mode + // publish signer (see CLAUDE.md "dev signer" invariant). + "0x35cdb23ff7fc86e8dccd577ca309bfea9c978d20", + // playground-app's own builder dev signer. + "0x41dccbd49b26c50d34355ed86ff0fa9e489d1e01", + // Lowercased at construction so a future entry pasted as EIP-55 + // checksummed hex (the format explorers copy) can't silently fail the + // lowercase `.has()` lookup in isKnownDevPublishSigner. + ].map((h160) => h160.toLowerCase()), +); + const REVIVE_TRACE_CALL_COMPAT_ERROR = "Incompatible runtime entry RuntimeCall(ReviveApi_trace_call)"; diff --git a/src/utils/decentralize/run.test.ts b/src/utils/decentralize/run.test.ts index cfa630d7..6a526aed 100644 --- a/src/utils/decentralize/run.test.ts +++ b/src/utils/decentralize/run.test.ts @@ -75,6 +75,8 @@ vi.mock("@parity/product-sdk-terminal/host", () => ({ requestResourceAllocation: vi.fn(), })); import { DEFAULT_MNEMONIC } from "@parity/polkadot-app-deploy"; +import { ss58Encode } from "@parity/product-sdk-address"; +import { seedToAccount } from "@parity/product-sdk-keys"; import type { ResolvedSigner } from "../signer.js"; import { DEV_PUBLISH_ADDRESS } from "../deploy/signerMode.js"; import type { DecentralizeLogEvent } from "./run.js"; @@ -276,6 +278,47 @@ describe("runDecentralize — playground publish metadata", () => { expect(arg.cwd).toBe("./dist/__repo_root__"); }); + it("routes publishes by on-chain identity, not --suri provenance (mirrors deploy/run.ts)", async () => { + // Dev mode with no explicit signer: the publish signer is the + // bare-root dev account, which IS a contract dev signer → publishDev. + await runDecentralize({ + source: { kind: "path", directory: "./dist" }, + label: "my-site", + fullDomain: "my-site.dot", + mode: "dev", + userSigner: null, + publishToPlayground: true, + env: "paseo-next-v2", + }); + const devArg = publishToPlaygroundMock.mock.calls[0][0] as { isDevSigner?: boolean }; + expect(devArg.isDevSigner).toBe(true); + + publishToPlaygroundMock.mockClear(); + + // An arbitrary --suri key resolves as source "dev" but its H160 is + // NOT a known dev signer: it must publish as a reveal-gated user + // (isDevSigner=false) — routing it through publishDev would revert + // Unauthorized on-chain. + const suriAccount = seedToAccount(DEFAULT_MNEMONIC, "//e2e-deployer"); + const arbitrarySuri: ResolvedSigner = { + signer: suriAccount.signer, + address: ss58Encode(suriAccount.publicKey), + source: "dev", + destroy: vi.fn(), + }; + await runDecentralize({ + source: { kind: "path", directory: "./dist" }, + label: "my-site", + fullDomain: "my-site.dot", + mode: "dev", + userSigner: arbitrarySuri, + publishToPlayground: true, + env: "paseo-next-v2", + }); + const suriArg = publishToPlaygroundMock.mock.calls[0][0] as { isDevSigner?: boolean }; + expect(suriArg.isDevSigner).toBe(false); + }); + it("url source records no repository, no moddable bit, and no project root", async () => { // Mirrored sites have no git source: even if a caller smuggled a repo // URL in, the contract for url mode is null/false/undefined — pinned diff --git a/src/utils/decentralize/run.ts b/src/utils/decentralize/run.ts index 4186349a..beb81ad1 100644 --- a/src/utils/decentralize/run.ts +++ b/src/utils/decentralize/run.ts @@ -35,6 +35,7 @@ import type { DeployLogEvent } from "../deploy/progress.js"; import { type DeployApproval, DEV_PUBLISH_ADDRESS, + isKnownDevPublishSigner, resolveSignerSetup, resolveStorageSignerOptions, type SignerMode, @@ -333,7 +334,11 @@ export async function runDecentralize( env, isPrivate: false, isModdable: repositoryUrl !== null, - isDevSigner: setup.publishSigner.source === "dev", + // Route by on-chain identity, not by `--suri` provenance — + // same rule as deploy/run.ts: only the contract's known dev + // signers may `publishDev`; any other local key publishes as + // a (reveal-gated) user. + isDevSigner: isKnownDevPublishSigner(setup.publishSigner), onLogEvent: (event) => onEvent?.({ kind: "playground-event", event }), onAllowancePrompt: allowancePrompt, }); diff --git a/src/utils/deploy/playground.test.ts b/src/utils/deploy/playground.test.ts index d4a7a866..8b9d5827 100644 --- a/src/utils/deploy/playground.test.ts +++ b/src/utils/deploy/playground.test.ts @@ -60,14 +60,18 @@ vi.mock("polkadot-api/ws", () => ({ })); // Likewise stub the connection + registry helpers. We capture the publish -// arguments so we can assert on them. +// arguments so we can assert on them. `publish` (user/phone) and `publishDev` +// (dev signer) are separate contract methods — the dev path must never fall +// through to `publish`, which is gated by `require_revealed()` on-chain. const publishTx = vi.fn(async () => ({ ok: true, txHash: "0xdead" })); +const publishDevTx = vi.fn(async () => ({ ok: true, txHash: "0xdead" })); vi.mock("../connection.js", () => ({ getConnection: vi.fn(async () => ({ raw: { assetHub: {} } })), })); vi.mock("../registry.js", () => ({ getRegistryContract: vi.fn(async () => ({ publish: { tx: publishTx }, + publishDev: { tx: publishDevTx }, })), })); vi.mock("../../telemetry.js", () => ({ @@ -103,6 +107,8 @@ const fakeSigner: ResolvedSigner = { beforeEach(() => { publishTx.mockClear(); publishTx.mockImplementation(async () => ({ ok: true, txHash: "0xdead" })); + publishDevTx.mockClear(); + publishDevTx.mockImplementation(async () => ({ ok: true, txHash: "0xdead" })); captureWarningMock.mockClear(); withSpanMock.mockClear(); getBulletinAllowanceSignerMock.mockClear(); @@ -509,18 +515,18 @@ describe("publishToPlayground", () => { expect.objectContaining({ __kind: "store" }), bulletinStorageSigner, ); + // 7 args: the trailing `is_dev_signer` boolean stays on the ABI + // for compatibility but the contract ignores it — always false. expect(publishTx).toHaveBeenCalledWith( "my-app.dot", "bafymeta", 1, - { - isSome: false, - value: "0x0000000000000000000000000000000000000000", - }, + "0x0000000000000000000000000000000000000000", "", false, false, ); + expect(publishDevTx).not.toHaveBeenCalled(); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -582,7 +588,26 @@ describe("publishToPlayground", () => { } }); - it("passes claimedOwnerH160 through as the dev-signer publish owner argument", async () => { + it("rejects claimedOwnerH160 without isDevSigner before any on-chain work", async () => { + // The registry honors the owner override only on publishDev; a user + // publish with owner ≠ caller reverts OwnerOverrideForbidden — after + // the metadata upload has been paid for. The API boundary must catch + // the combination before the upload. + await expect( + publishToPlayground({ + domain: "claimed-user-app", + publishSigner: fakeSigner, + repositoryUrl: null, + cwd: "/definitely/not/a/repo", + claimedOwnerH160: "0x1234567890abcdef1234567890abcdef12345678", + }), + ).rejects.toThrow(/claimedOwnerH160 requires isDevSigner/); + expect(submitAndWatch).not.toHaveBeenCalled(); // no metadata upload + expect(publishTx).not.toHaveBeenCalled(); + expect(publishDevTx).not.toHaveBeenCalled(); + }); + + it("passes claimedOwnerH160 through as the publishDev owner argument", async () => { await publishToPlayground({ domain: "claimed-app", publishSigner: fakeSigner, @@ -591,18 +616,15 @@ describe("publishToPlayground", () => { claimedOwnerH160: "0x1234567890abcdef1234567890abcdef12345678", isDevSigner: true, }); - expect(publishTx).toHaveBeenCalledWith( + expect(publishDevTx).toHaveBeenCalledWith( "claimed-app.dot", "bafymeta", 1, - { - isSome: true, - value: "0x1234567890abcdef1234567890abcdef12345678", - }, + "0x1234567890abcdef1234567890abcdef12345678", "", false, - true, ); + expect(publishTx).not.toHaveBeenCalled(); }); it("passes visibility=0 when isPrivate is true", async () => { @@ -617,17 +639,14 @@ describe("publishToPlayground", () => { "secret.dot", "bafymeta", 0, - { - isSome: false, - value: "0x0000000000000000000000000000000000000000", - }, + "0x0000000000000000000000000000000000000000", "", false, false, ); }); - it("routes dev signer publishes through publish with is_dev_signer=true", async () => { + it("routes dev signer publishes through the separate publishDev method (6 args, no flag)", async () => { await publishToPlayground({ domain: "modded-by-dev", publishSigner: fakeSigner, @@ -636,18 +655,19 @@ describe("publishToPlayground", () => { isModdable: true, isDevSigner: true, }); - expect(publishTx).toHaveBeenCalledWith( + // The zero address is the "no claimed owner" sentinel — the contract + // records env::caller() (the dev signer) as owner. + expect(publishDevTx).toHaveBeenCalledWith( "modded-by-dev.dot", "bafymeta", 1, - { - isSome: false, - value: "0x0000000000000000000000000000000000000000", - }, + "0x0000000000000000000000000000000000000000", "", true, - true, ); + // Never `publish` for dev: it's reveal-gated on-chain and would revert + // NotRevealed for the (never-revealed) dev signer. + expect(publishTx).not.toHaveBeenCalled(); }); it("forwards moddedFrom captured by `dot mod` in dot.json to registry.publish", async () => { @@ -664,10 +684,7 @@ describe("publishToPlayground", () => { "my-mod.dot", "bafymeta", 1, - { - isSome: false, - value: "0x0000000000000000000000000000000000000000", - }, + "0x0000000000000000000000000000000000000000", "original.dot", false, false, @@ -702,10 +719,7 @@ describe("publishToPlayground", () => { "my-mod.dot", "bafymeta", 1, - { - isSome: false, - value: "0x0000000000000000000000000000000000000000", - }, + "0x0000000000000000000000000000000000000000", "steampunk-lizard-spock01.dot", false, false, @@ -736,10 +750,7 @@ describe("publishToPlayground", () => { "my-mod.dot", "bafymeta", 1, - { - isSome: false, - value: "0x0000000000000000000000000000000000000000", - }, + "0x0000000000000000000000000000000000000000", "steampunk-lizard-spock01.dot", false, false, @@ -765,10 +776,7 @@ describe("publishToPlayground", () => { "my-mod.dot", "bafymeta", 1, - { - isSome: false, - value: "0x0000000000000000000000000000000000000000", - }, + "0x0000000000000000000000000000000000000000", "original.dot", false, false, @@ -797,10 +805,7 @@ describe("publishToPlayground", () => { "my-mod.dot", "bafymeta", 1, - { - isSome: false, - value: "0x0000000000000000000000000000000000000000", - }, + "0x0000000000000000000000000000000000000000", "original.dot", false, false, @@ -927,4 +932,37 @@ describe("publishToPlayground", () => { ).rejects.toThrow(/reverted: NotRevealed/); expect(publishTx).toHaveBeenCalledTimes(1); }); + + it("appends the become-a-builder remedy to a NotRevealed revert (keyed on the decoded reason)", async () => { + publishTx.mockReset(); + publishTx.mockResolvedValue({ ok: false, error: { reason: "NotRevealed" } } as any); + + await expect( + publishToPlayground({ + domain: "unrevealed", + publishSigner: fakeSigner, + repositoryUrl: null, + cwd: "/definitely/not/a/repo", + }), + ).rejects.toThrow(/Become a builder/); + }); + + it("appends a dev-signer remedy to an Unauthorized publishDev revert", async () => { + // The two dev-branch Unauthorized causes (caller not a known dev + // signer / re-publishing a non-dev-published app) both point at this + // hint rather than a bare revert name. + publishDevTx.mockReset(); + publishDevTx.mockResolvedValue({ ok: false, error: { reason: "Unauthorized" } } as any); + + await expect( + publishToPlayground({ + domain: "not-a-dev", + publishSigner: fakeSigner, + repositoryUrl: null, + cwd: "/definitely/not/a/repo", + isDevSigner: true, + }), + ).rejects.toThrow(/known dev signers/); + expect(publishTx).not.toHaveBeenCalled(); + }); }); diff --git a/src/utils/deploy/playground.ts b/src/utils/deploy/playground.ts index ec61d554..e7f47947 100644 --- a/src/utils/deploy/playground.ts +++ b/src/utils/deploy/playground.ts @@ -17,12 +17,15 @@ * Own playground-registry publish flow. * * We upload the metadata JSON through Bulletin `TransactionStorage.store` - * with the product-scoped RFC-0010 Bulletin allowance account, then call - * `registry.publish(...)` (with the `is_dev_signer` flag) ourselves via - * `getRegistryContract()`. + * with the product-scoped RFC-0010 Bulletin allowance account, then call the + * registry ourselves via `getRegistryContract()`: `publish(...)` for user + * (phone) publishes, `publishDev(...)` for dev-signer publishes. * Phone publishing is signed by the user's product account so the contract's - * `env::caller()` matches their address. Dev publishing is signed by the dev - * account and may pass a claimed owner for the playground-app "myApps" view. + * `env::caller()` matches their address; `publish` is gated by + * `require_revealed()` (the identity spine must report the caller verified). + * Dev publishing is signed by the dev account, authorized by `env::caller()` + * matching a known dev signer (exempt from the reveal gate), and may pass a + * claimed owner for the playground-app "myApps" view. * * We deliberately do NOT use `polkadot-app-deploy.deploy()` for the metadata * upload: `deploy()` unconditionally runs a DotNS `register()` + @@ -40,6 +43,7 @@ import { calculateCid } from "@parity/product-sdk-cloud-storage"; import { submitAndWatch, withRetry } from "@parity/product-sdk-tx"; import { unwrapResult } from "../tx.js"; import { getRegistryContract } from "../registry.js"; +import { ZERO_H160 } from "../contractManifest.js"; import { getConnection } from "../connection.js"; import { getChainConfig, type Env } from "../../config.js"; import { getBulletinDescriptor } from "../descriptors.js"; @@ -75,7 +79,12 @@ const REGISTRY_RETRY_DELAY_MS = 6_000; * appear while a DotNS reveal propagates across nodes right after the dotns step * — exclude it from `deterministic` here rather than dropping fail-fast wholesale. */ -function classifyRevert(error: unknown): { deterministic: boolean; detail: string } { +function classifyRevert(error: unknown): { + deterministic: boolean; + detail: string; + /** Decoded revert name (e.g. "NotRevealed") when the SDK surfaced one. */ + reason?: string; +} { const e = (typeof error === "object" && error !== null ? error : {}) as { reason?: unknown; data?: unknown; @@ -101,7 +110,37 @@ function classifyRevert(error: unknown): { deterministic: boolean; detail: strin const deterministic = Boolean( reason || data || e.dispatchError != null || (formatted && /revert/i.test(formatted)), ); - return { deterministic, detail }; + return { deterministic, detail, reason }; +} + +/** + * Map a decoded revert to an actionable remedy. Keyed on the structured + * `reason` when the SDK decoded one, falling back to a substring match on the + * flattened detail — a revert surfacing via the `TxDispatchError` path + * flattens to e.g. `Revive.ContractReverted`, which carries no reason name, + * so the fallback can't be the only mechanism (nor can it be dropped). + */ +function revertHint(reason: string | undefined, detail: string, isDevSigner: boolean): string { + const matches = (name: string) => + reason === name || detail.toLowerCase().includes(name.toLowerCase()); + if (matches("NotRevealed")) { + // The identity gate normally blocks before we get here, so this covers + // races (a reveal still propagating), `--suri` keys the session-based + // gate can't vouch for, and a registry whose verifier isn't wired yet. + return ( + " — the signing account is not revealed as a builder in the identity" + + ' spine. Open the playground app and complete the "Become a builder"' + + " flow for this account, then retry." + ); + } + if (matches("Unauthorized")) { + return isDevSigner + ? " — publishDev is restricted to the registry's known dev signers," + + " and dev mode can only re-publish apps first published from dev" + + " mode; check the signing account and the domain's current owner." + : " — this domain was already published by a different account."; + } + return ""; } /** Thrown for a deterministic contract revert so the retry loop can fail fast. */ @@ -114,17 +153,21 @@ export interface PublishToPlaygroundOptions { * Signer that submits the registry publish tx. In phone mode this is the * user's session signer and calls `publish(...)` (caller becomes owner). * In dev mode this is a dev signer (Alice / `--suri`) and calls - * `publish(...)` with `is_dev_signer: true`; `claimedOwnerH160` carries the - * H160 to record as owner. + * `publishDev(...)`; `claimedOwnerH160` carries the H160 to record as + * owner. */ publishSigner: ResolvedSigner; /** * Optional H160 to record as the app owner via the contract's `owner` * parameter. Used by dev mode + active session so the app shows in the * user's MyApps view even though the tx is signed by Alice. `null` or - * omitted ⇒ contract defaults to caller (`publishSigner.address` - * translated to H160), which is correct for phone mode and pure-dev - * throwaway. + * omitted ⇒ we pass the zero address (the ABI has no Option shape; the + * zero H160 is the "none" sentinel) and the contract records + * `env::caller()` as owner, which is correct for phone mode and pure-dev + * throwaway. Valid ONLY together with `isDevSigner` — the contract honors + * the override only on publishDev (a user publish with owner ≠ caller + * reverts `OwnerOverrideForbidden`), so `publishToPlayground` rejects the + * combination synchronously. */ claimedOwnerH160?: `0x${string}` | null; /** Repository URL to record in metadata. `null` = omit the field entirely. */ @@ -181,9 +224,12 @@ export interface PublishToPlaygroundOptions { moddedFrom?: string; /** * True when the publish is signed by the dev signer (Alice / `--suri`) - * rather than the user's session. Dev publishes set the ungated - * `is_dev_signer` flag on `publish(...)`, which records the app without - * awarding deploy XP or source-app mod XP. + * rather than the user's session. Dev publishes go through the separate + * `publishDev(...)` method — authorized on-chain by `env::caller()` + * matching a known dev signer (NOT a calldata flag) and exempt from the + * reveal gate — which records the app without awarding deploy XP or + * source-app mod XP. (`publish` still carries a trailing `is_dev_signer` + * boolean for ABI compatibility, but the contract ignores it.) */ isDevSigner?: boolean; } @@ -354,6 +400,20 @@ export function readModdedFrom(cwd: string): string | null { export async function publishToPlayground( options: PublishToPlaygroundOptions, ): Promise { + // Validate the owner-override combination BEFORE any on-chain work: the + // registry honors `claimedOwnerH160` only on publishDev — a user publish + // with owner ≠ caller reverts OwnerOverrideForbidden, and by then the + // metadata upload has already been paid for. Fail at the API boundary + // instead (this surface is consumed by SDK callers like RevX, not just + // our own deploy pipelines). + if (options.claimedOwnerH160 && !options.isDevSigner) { + throw new Error( + "claimedOwnerH160 requires isDevSigner: the registry honors an owner " + + "override only on publishDev — a user publish always records " + + "env::caller() as owner (pass claimedOwnerH160: null).", + ); + } + const { label, fullDomain } = normalizeDomain(options.domain); const readme = options.cwd ? readReadme(options.cwd) : null; @@ -449,15 +509,12 @@ export async function publishToPlayground( "publish playground registry entry", { "cli.deploy.domain": fullDomain }, async () => { - // Encode the Option
owner parameter. None ⇒ contract - // defaults to env::caller(). Some(h160) ⇒ recorded as the app - // owner regardless of who signed the tx. - const owner = options.claimedOwnerH160 - ? { isSome: true as const, value: options.claimedOwnerH160 } - : { - isSome: false as const, - value: "0x0000000000000000000000000000000000000000" as const, - }; + // The `owner` parameter is a plain Address (the ABI has no Option + // shape). Zero address (ZERO_H160) ⇒ contract records + // env::caller() as owner. Non-zero h160 ⇒ recorded as the app + // owner regardless of who signed the tx (dev path only — the + // combination with a user publish was rejected at function entry). + const owner = options.claimedOwnerH160 ?? ZERO_H160; let lastError: unknown; for (let attempt = 1; attempt <= MAX_REGISTRY_RETRIES; attempt++) { @@ -469,29 +526,38 @@ export async function publishToPlayground( // argument, so an empty string records no lineage edge. const moddedFromArg = moddedFrom ?? ""; const isModdable = options.isModdable ?? false; - const isDevSigner = options.isDevSigner ?? false; - // contracts@0.9 dropped the separate `publishDev` method: - // the dev-signer path is now the same `publish` call with - // the trailing `is_dev_signer` boolean set. `true` records - // the app without awarding mod XP (the former `publishDev` - // behaviour); `false` is the normal user publish. - const result = await registry.publish.tx( + // Dev publishes MUST go through the separate `publishDev` + // method: `publish` is gated fail-closed by + // `require_revealed()` (the dev signer is never revealed + // in the identity spine, so it would revert `NotRevealed`) + // and ignores its trailing `is_dev_signer` boolean — the + // dev path is authorized by `env::caller()` matching a + // known dev signer, not by calldata. The flag stays on the + // `publish` ABI for compatibility only; pass `false`. + // Both methods share the first six args — keep them in one + // tuple so an arg-order edit can't silently diverge per + // branch. + const args = [ fullDomain, metadataCid, visibility, owner, moddedFromArg, isModdable, - isDevSigner, - ); + ] as const; + const result = options.isDevSigner + ? await registry.publishDev.tx(...args) + : await registry.publish.tx(...args, false); if (!result.ok) { - const { deterministic, detail } = classifyRevert(result.error); + const { deterministic, detail, reason } = classifyRevert(result.error); const cause = result.error instanceof Error ? result.error : undefined; if (deterministic) { - // Deterministic — surface immediately; retrying the - // identical tx would revert the same way. + // Deterministic — surface immediately with a + // remedy; retrying the identical tx would revert + // the same way. + const hint = revertHint(reason, detail, options.isDevSigner ?? false); throw new RegistryPublishRevertError( - `Registry publish transaction reverted: ${detail}`, + `Registry publish transaction reverted: ${detail}${hint}`, { cause }, ); } diff --git a/src/utils/deploy/run.ts b/src/utils/deploy/run.ts index 1a1e8864..a62403d3 100644 --- a/src/utils/deploy/run.ts +++ b/src/utils/deploy/run.ts @@ -27,6 +27,7 @@ import { runBuild, loadDetectInput, detectBuildConfig, type BuildConfig } from " import { publishToPlayground, normalizeDomain } from "./playground.js"; import { assertBuildDirExists } from "./buildDir.js"; import { + isKnownDevPublishSigner, resolveSignerSetup, resolveStorageSignerOptions, type SignerMode, @@ -304,7 +305,10 @@ export async function runDeploy(options: RunDeployOptions): Promise ({ import { DEFAULT_MNEMONIC } from "@parity/polkadot-app-deploy"; import { ss58Encode } from "@parity/product-sdk-address"; +import { seedToAccount } from "@parity/product-sdk-keys"; import { + isKnownDevPublishSigner, resolveSignerSetup, resolveStorageSignerOptions, DEV_PUBLISH_ADDRESS, @@ -57,6 +59,31 @@ function fakeSigner( }; } +describe("isKnownDevPublishSigner", () => { + // The contract authorizes `publishDev` by env::caller() matching one of + // its compiled-in dev-signer H160s. These tests pin the routing rule: + // on-chain identity decides, not `--suri` provenance. + + it("recognizes the polkadot-app-deploy bare-root dev publish signer", () => { + expect(isKnownDevPublishSigner(fakeSigner("dev", DEV_PUBLISH_ADDRESS))).toBe(true); + }); + + it("rejects an arbitrary --suri key (must publish as a reveal-gated user)", () => { + // Same derivation the e2e deployer uses: dev provenance, but its H160 + // is not a contract dev signer, so publishDev would revert Unauthorized. + const e2e = seedToAccount(DEFAULT_MNEMONIC, "//e2e-deployer"); + expect(isKnownDevPublishSigner(fakeSigner("dev", ss58Encode(e2e.publicKey)))).toBe(false); + }); + + it("rejects session signers regardless of address", () => { + expect(isKnownDevPublishSigner(fakeSigner("session", DEV_PUBLISH_ADDRESS))).toBe(false); + }); + + it("returns false (not a throw) for an undecodable address", () => { + expect(isKnownDevPublishSigner(fakeSigner("dev", "not-an-ss58"))).toBe(false); + }); +}); + describe("resolveSignerSetup — dev mode", () => { it("no publish, no funding → empty approvals, explicit dev mnemonic, null publishSigner", () => { const result = resolveSignerSetup({ diff --git a/src/utils/deploy/signerMode.ts b/src/utils/deploy/signerMode.ts index b97b8c9e..350077a4 100644 --- a/src/utils/deploy/signerMode.ts +++ b/src/utils/deploy/signerMode.ts @@ -48,10 +48,11 @@ */ import { DEFAULT_MNEMONIC, type DeployOptions } from "@parity/polkadot-app-deploy"; -import { ss58Encode } from "@parity/product-sdk-address"; +import { ss58Encode, ss58ToH160 } from "@parity/product-sdk-address"; import type { CloudStorageApi } from "@parity/product-sdk-cloud-storage"; import { seedToAccount } from "@parity/product-sdk-keys"; import { getBulletinAllowanceSigner, type AllowancePrompt } from "../allowances/bulletin.js"; +import { KNOWN_DEV_SIGNER_H160S } from "../contractManifest.js"; import type { ResolvedSigner } from "../signer.js"; import type { DeployPlan } from "./availability.js"; @@ -101,6 +102,29 @@ export function createDevPublishSigner(): ResolvedSigner { export type SignerMode = "dev" | "phone"; +/** + * Whether a resolved publish signer is one of the registry contract's + * KNOWN dev signers — the only callers `publishDev(...)` authorizes + * (`env::caller()` must match; the old calldata `is_dev_signer` flag is + * ignored by the contract). + * + * This deliberately keys on the signer's on-chain identity, NOT on + * `source === "dev"`: any `--suri` key resolves as source "dev", but an + * arbitrary suri account (e.g. the dedicated e2e deployer) is NOT a contract + * dev signer — it must publish through the normal reveal-gated `publish(...)` + * as a revealed builder. Routing it through `publishDev` would revert + * `Unauthorized`; routing a real dev signer through `publish` would revert + * `NotRevealed`. + */ +export function isKnownDevPublishSigner(signer: ResolvedSigner): boolean { + if (signer.source !== "dev") return false; + try { + return KNOWN_DEV_SIGNER_H160S.has(ss58ToH160(signer.address).toLowerCase()); + } catch { + return false; + } +} + /** * The SS58 address that will own and sign a deploy's DotNS name, by mode: * - phone: the user's session account (the caller IS the user) diff --git a/src/utils/identity/identityGate.test.ts b/src/utils/identity/identityGate.test.ts index 10295fac..8a377567 100644 --- a/src/utils/identity/identityGate.test.ts +++ b/src/utils/identity/identityGate.test.ts @@ -16,12 +16,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; // Boundary mocks: the gate composes session lookup (auth.ts) + a read-only -// registry dry-run (registry.ts). We never want a real adapter / network here. -const { findSessionMock, deriveSessionAddressesMock, getReadOnlyRegistryContractMock } = vi.hoisted( +// identity-spine dry-run (registry.ts). We never want a real adapter / network +// here. +const { findSessionMock, deriveSessionAddressesMock, getReadOnlyIdentityContractMock } = vi.hoisted( () => ({ findSessionMock: vi.fn(), deriveSessionAddressesMock: vi.fn(), - getReadOnlyRegistryContractMock: vi.fn(), + getReadOnlyIdentityContractMock: vi.fn(), }), ); @@ -31,13 +32,11 @@ vi.mock("../auth.js", () => ({ })); vi.mock("../registry.js", () => ({ - getReadOnlyRegistryContract: getReadOnlyRegistryContractMock, + getReadOnlyIdentityContract: getReadOnlyIdentityContractMock, })); -import { checkIdentityGate, isAnonymousRoot } from "./identityGate.js"; +import { checkIdentityGate } from "./identityGate.js"; -const ZERO = ("0x" + "00".repeat(32)) as `0x${string}`; -const REVEALED = ("0x" + "11".repeat(32)) as `0x${string}`; const H160 = "0xbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef" as `0x${string}`; function fakeHandle() { @@ -45,10 +44,8 @@ function fakeHandle() { return { adapter: { destroy }, address: "5x", session: { rootAccountId: new Uint8Array(32) } }; } -function fakeRegistry( - query: (addr: `0x${string}`) => Promise<{ success: boolean; value?: unknown }>, -) { - return { getRootAccount: { query: vi.fn(query) } }; +function fakeSpine(query: (addr: `0x${string}`) => Promise<{ success: boolean; value?: unknown }>) { + return { isVerified: { query: vi.fn(query) } }; } const FAST = { attempts: 2, delayMs: 0 }; @@ -62,57 +59,21 @@ beforeEach(() => { }); }); -describe("isAnonymousRoot", () => { - it("treats the 32-zero-byte hex sentinel as anonymous", () => { - expect(isAnonymousRoot(ZERO)).toBe(true); - expect(isAnonymousRoot("0X" + "00".repeat(32))).toBe(true); - expect(isAnonymousRoot("00".repeat(32))).toBe(true); // no 0x prefix - expect(isAnonymousRoot("0x")).toBe(true); // empty body - }); - - it("treats a non-zero root as revealed", () => { - expect(isAnonymousRoot(REVEALED)).toBe(false); - expect(isAnonymousRoot("0x" + "00".repeat(31) + "01")).toBe(false); - expect( - isAnonymousRoot("0xAbC0000000000000000000000000000000000000000000000000000000000000"), - ).toBe(false); - }); - - it("handles byte-array representations", () => { - expect(isAnonymousRoot(new Uint8Array(32))).toBe(true); - const nonZero = new Uint8Array(32); - nonZero[31] = 1; - expect(isAnonymousRoot(nonZero)).toBe(false); - expect(isAnonymousRoot([0, 0, 0])).toBe(true); - expect(isAnonymousRoot([0, 1, 0])).toBe(false); - }); - - it("treats null/undefined as anonymous", () => { - expect(isAnonymousRoot(null)).toBe(true); - expect(isAnonymousRoot(undefined)).toBe(true); - }); - - it("throws on an unrecognized representation (forces unverifiable upstream)", () => { - expect(() => isAnonymousRoot(5 as unknown)).toThrow(); - expect(() => isAnonymousRoot({} as unknown)).toThrow(); - }); -}); - describe("checkIdentityGate", () => { - it("returns not-logged-in and never reads the registry when no session exists", async () => { + it("returns not-logged-in and never reads the spine when no session exists", async () => { findSessionMock.mockResolvedValue(null); const result = await checkIdentityGate({} as any, FAST); expect(result).toEqual({ status: "not-logged-in" }); - expect(getReadOnlyRegistryContractMock).not.toHaveBeenCalled(); + expect(getReadOnlyIdentityContractMock).not.toHaveBeenCalled(); }); - it("returns revealed for a non-zero root and releases the session adapter", async () => { + it("returns revealed when isVerified is true and releases the session adapter", async () => { const handle = fakeHandle(); findSessionMock.mockResolvedValue(handle); - getReadOnlyRegistryContractMock.mockResolvedValue( - fakeRegistry(async () => ({ success: true, value: REVEALED })), + getReadOnlyIdentityContractMock.mockResolvedValue( + fakeSpine(async () => ({ success: true, value: true })), ); const result = await checkIdentityGate({} as any, FAST); @@ -121,11 +82,11 @@ describe("checkIdentityGate", () => { expect(handle.adapter.destroy).toHaveBeenCalledTimes(1); }); - it("returns anonymous for the zero-root sentinel and releases the adapter", async () => { + it("returns anonymous when isVerified is false and releases the adapter", async () => { const handle = fakeHandle(); findSessionMock.mockResolvedValue(handle); - getReadOnlyRegistryContractMock.mockResolvedValue( - fakeRegistry(async () => ({ success: true, value: ZERO })), + getReadOnlyIdentityContractMock.mockResolvedValue( + fakeSpine(async () => ({ success: true, value: false })), ); const result = await checkIdentityGate({} as any, FAST); @@ -134,24 +95,39 @@ describe("checkIdentityGate", () => { expect(handle.adapter.destroy).toHaveBeenCalledTimes(1); }); + it("fails fast to unverifiable on a non-boolean response (ABI drift guard, no retry)", async () => { + const handle = fakeHandle(); + findSessionMock.mockResolvedValue(handle); + const spine = fakeSpine(async () => ({ success: true, value: "0x01" })); + getReadOnlyIdentityContractMock.mockResolvedValue(spine); + + const result = await checkIdentityGate({} as any, FAST); + + expect(result.status).toBe("unverifiable"); + // Drift is deterministic — the retry budget (attempts=2 here) must + // NOT be spent re-querying an answer that cannot change. + expect(spine.isVerified.query).toHaveBeenCalledTimes(1); + expect(handle.adapter.destroy).toHaveBeenCalledTimes(1); + }); + it("returns unverifiable when the dry-run fails on every attempt", async () => { const handle = fakeHandle(); findSessionMock.mockResolvedValue(handle); - const registry = fakeRegistry(async () => ({ success: false })); - getReadOnlyRegistryContractMock.mockResolvedValue(registry); + const spine = fakeSpine(async () => ({ success: false })); + getReadOnlyIdentityContractMock.mockResolvedValue(spine); const result = await checkIdentityGate({} as any, FAST); expect(result.status).toBe("unverifiable"); - expect(registry.getRootAccount.query).toHaveBeenCalledTimes(2); // retried + expect(spine.isVerified.query).toHaveBeenCalledTimes(2); // retried expect(handle.adapter.destroy).toHaveBeenCalledTimes(1); }); it("returns unverifiable when the query throws", async () => { const handle = fakeHandle(); findSessionMock.mockResolvedValue(handle); - getReadOnlyRegistryContractMock.mockResolvedValue( - fakeRegistry(async () => { + getReadOnlyIdentityContractMock.mockResolvedValue( + fakeSpine(async () => { throw new Error("RPC down"); }), ); @@ -162,16 +138,16 @@ describe("checkIdentityGate", () => { expect(handle.adapter.destroy).toHaveBeenCalledTimes(1); }); - it("uses an injected registry without re-resolving its own", async () => { + it("uses an injected spine handle without re-resolving its own", async () => { const handle = fakeHandle(); findSessionMock.mockResolvedValue(handle); - const registry = fakeRegistry(async () => ({ success: true, value: REVEALED })); + const spine = fakeSpine(async () => ({ success: true, value: true })); - const result = await checkIdentityGate({} as any, { ...FAST, registry }); + const result = await checkIdentityGate({} as any, { ...FAST, identity: spine }); expect(result).toEqual({ status: "revealed", productH160: H160 }); - expect(registry.getRootAccount.query).toHaveBeenCalledTimes(1); - expect(getReadOnlyRegistryContractMock).not.toHaveBeenCalled(); + expect(spine.isVerified.query).toHaveBeenCalledTimes(1); + expect(getReadOnlyIdentityContractMock).not.toHaveBeenCalled(); }); it("returns unverifiable (and releases the adapter) when the session can't be derived", async () => { @@ -185,6 +161,6 @@ describe("checkIdentityGate", () => { expect(result.status).toBe("unverifiable"); expect(handle.adapter.destroy).toHaveBeenCalledTimes(1); - expect(getReadOnlyRegistryContractMock).not.toHaveBeenCalled(); + expect(getReadOnlyIdentityContractMock).not.toHaveBeenCalled(); }); }); diff --git a/src/utils/identity/identityGate.ts b/src/utils/identity/identityGate.ts index 901c6a27..b7b8cb88 100644 --- a/src/utils/identity/identityGate.ts +++ b/src/utils/identity/identityGate.ts @@ -22,21 +22,27 @@ * flow. Anonymous accounts earn no competition points, so the CLI refuses to * act for them. * - * "Revealed" is decided exactly as the playground-app decides it - * (`hasRevealedIdentity`): `playground-registry.getRootAccount(productH160)` - * returns a NON-zero bytes32. The contract `unwrap_or`s a missing binding to 32 - * zero bytes and never reverts, so the zero sentinel IS the "anonymous" answer. + * "Revealed" is decided by the identity spine (`@w3s/playground-identity`): + * `isVerified(productH160)` returns true. This is the EXACT predicate the + * registry's fail-closed publish gate (`require_revealed`) delegates to + * on-chain, so for PHONE-mode publishes (where the session's product account + * IS `env::caller()`) the gate's verdict predicts scored-publish success + * precisely. In `--suri` mode it does NOT: the gate still checks the + * session's product H160 while the chain checks the suri key's H160, so a + * revealed session + unrevealed suri key passes here and reverts NotRevealed + * at publish (and vice versa). The spine `unwrap_or`s a missing binding to + * false and never reverts, so `false` IS the "anonymous" answer. * * This module is pure logic (no React/Ink). The session's product H160 is * derived signer-free from the persisted login (`findSession` -> * `deriveSessionAddresses`), and the read uses the keyless revive origin - * (`getReadOnlyRegistryContract`), so evaluating the gate needs neither a phone - * tap nor a mapped/funded account. + * (`getReadOnlyIdentityContract`), so evaluating the gate needs neither a + * phone tap nor a mapped/funded account. */ import type { PolkadotClient } from "polkadot-api"; import { findSession, deriveSessionAddresses } from "../auth.js"; -import { getReadOnlyRegistryContract } from "../registry.js"; +import { getReadOnlyIdentityContract } from "../registry.js"; export type IdentityGateResult = | { status: "revealed"; productH160: `0x${string}` } @@ -47,20 +53,28 @@ export type IdentityGateResult = /** Blocked outcomes — everything except `revealed`. */ export type BlockedIdentityStatus = "not-logged-in" | "anonymous" | "unverifiable"; -interface RootQueryResult { +interface VerifiedQueryResult { success: boolean; value?: unknown; } /** - * Minimal structural view of the registry handle. `getReadOnlyRegistryContract` - * returns a runtime Proxy (via `suppressReviveTraceNoise`) whose full typing we - * don't want to depend on here. The generated ABI does expose `getRootAccount` - * (`.cdm/contracts.d.ts`, `response: SizedHex<32>` — i.e. a hex string); we - * narrow to just the one read method we call. + * Minimal structural view of the identity-spine handle. + * `getReadOnlyIdentityContract` returns a runtime Proxy (via + * `suppressReviveTraceNoise`) whose full typing we don't want to depend on + * here. The generated ABI does expose `isVerified` (`.cdm/contracts.d.ts`, + * `response: boolean`); we narrow to just the one read method we call. + * + * NOTE on the `as unknown as IdentitySpine` seam below: it looks removable + * (locally the `.cdm` augmentation makes the handle structurally assignable + * with no cast) but is NOT — `.cdm` is gitignored and CI typechecks without + * it, where `getContract` returns the un-augmented `Contract`, + * whose string-index methods satisfy property ACCESS but not structural + * ASSIGNABILITY. Compile-time drift protection therefore can't exist in CI; + * the real guard is queryVerified's runtime AbiDriftError. */ -export interface IdentityRegistry { - getRootAccount: { query(account: `0x${string}`): Promise }; +export interface IdentitySpine { + isVerified: { query(account: `0x${string}`): Promise }; } interface GateOptions { @@ -69,51 +83,51 @@ interface GateOptions { /** Delay between retries in ms. Defaults to 250. */ delayMs?: number; /** - * Pre-resolved registry handle. Callers that already built one (e.g. `mod`) - * pass it to avoid a second meta-registry resolution + Revive dry-run. When - * omitted, the gate resolves its own from `rawAssetHubClient`. + * Pre-resolved identity-spine handle. Callers that already built one (e.g. + * `mod`) pass it to avoid a second meta-registry resolution + Revive + * dry-run. When omitted, the gate resolves its own from + * `rawAssetHubClient`. */ - registry?: IdentityRegistry; + identity?: IdentitySpine; } function describe(err: unknown): string { return err instanceof Error ? err.message : String(err); } -const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); +/** Deterministic bundled-ABI vs live-contract mismatch — retrying can't help. */ +class AbiDriftError extends Error {} -/** - * Whether a `getRootAccount` result represents an anonymous (unbound) account. - * - * Robust to the representations a bytes32 contract output can arrive as: a - * `0x`-prefixed (or bare) hex string, a `Uint8Array`/number array, or - * null/undefined (treated as anonymous). Throws on anything unrecognized so the - * orchestrator degrades to `unverifiable` rather than guessing. - */ -export function isAnonymousRoot(value: unknown): boolean { - if (value === null || value === undefined) return true; - if (typeof value === "string") { - const hex = value.slice(0, 2).toLowerCase() === "0x" ? value.slice(2) : value; - return hex.length === 0 || /^0+$/.test(hex); - } - if (value instanceof Uint8Array) return value.every((b) => b === 0); - if (Array.isArray(value)) return value.every((b) => Number(b) === 0); - throw new Error(`Unrecognized root account representation: ${typeof value}`); -} +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); -async function queryRoot( - registry: IdentityRegistry, +async function queryVerified( + spine: IdentitySpine, account: `0x${string}`, attempts: number, delayMs: number, -): Promise { +): Promise { let lastError: unknown; for (let i = 0; i < attempts; i++) { try { - const res = await registry.getRootAccount.query(account); - if (res.success) return res.value; - lastError = new Error("registry.getRootAccount dry-run was rejected (success=false)"); + const res = await spine.isVerified.query(account); + if (res.success) { + // Anything but a strict boolean means the ABI we bundle and + // the live contract disagree — deterministic, so fail straight + // to `unverifiable` upstream instead of burning the retry + // budget (which exists for transient RPC blips) on an answer + // that cannot change between attempts. + if (typeof res.value !== "boolean") { + throw new AbiDriftError( + `Unrecognized isVerified response (${typeof res.value}): ` + + `${JSON.stringify(res.value)} — bundled ABI vs live contract drift?`, + ); + } + return res.value; + } + lastError = new Error("identity.isVerified dry-run was rejected (success=false)"); } catch (err) { + // Drift is deterministic — skip the remaining retry budget. + if (err instanceof AbiDriftError) throw err; lastError = err; } if (i < attempts - 1 && delayMs > 0) await sleep(delayMs); @@ -151,13 +165,15 @@ export async function checkIdentityGate( } try { - const registry = - opts.registry ?? - ((await getReadOnlyRegistryContract(rawAssetHubClient)) as unknown as IdentityRegistry); - const root = await queryRoot(registry, productH160, attempts, delayMs); - return isAnonymousRoot(root) - ? { status: "anonymous", productH160 } - : { status: "revealed", productH160 }; + // Codegen seam — see the IdentitySpine doc comment for why this cast + // must stay (CI typechecks without the gitignored .cdm augmentation). + const spine = + opts.identity ?? + ((await getReadOnlyIdentityContract(rawAssetHubClient)) as unknown as IdentitySpine); + const verified = await queryVerified(spine, productH160, attempts, delayMs); + return verified + ? { status: "revealed", productH160 } + : { status: "anonymous", productH160 }; } catch (err) { return { status: "unverifiable", detail: describe(err) }; } diff --git a/src/utils/registry.test.ts b/src/utils/registry.test.ts index c2729b89..459228b7 100644 --- a/src/utils/registry.test.ts +++ b/src/utils/registry.test.ts @@ -36,12 +36,19 @@ vi.mock("@parity/product-sdk-descriptors/paseo-asset-hub", () => ({ vi.mock("./contractManifest.js", () => ({ PLAYGROUND_REGISTRY_CONTRACT: "@w3s/playground-registry", + PLAYGROUND_IDENTITY_CONTRACT: "@w3s/playground-identity", suppressReviveTraceNoise: (contract: unknown) => contract, // Pass-through wrapper so the live resolution runs unchanged in tests. withoutReviveTraceNoise: (fn: () => unknown) => fn(), })); -import { getRegistryContract, getReadOnlyRegistryContract } from "./registry.js"; +import { + getRegistryContract, + getReadOnlyRegistryContract, + getReadOnlyIdentityContract, + getReadOnlyPlaygroundContracts, + queryMetadataUri, +} from "./registry.js"; // pallet-revive's keyless pallet account ("modlpy/reviv" + 20 zero bytes), // frozen here so a regression back to Alice (or any other origin) fails loudly. @@ -95,29 +102,98 @@ describe("getRegistryContract", () => { }); }); -describe("getReadOnlyRegistryContract", () => { - it("resolves the registry live with the read-only origin and no signer", async () => { +describe("getReadOnlyPlaygroundContracts", () => { + it("resolves registry + identity in ONE live pass with the read-only origin and no signer", async () => { const rawClient = {} as any; - await getReadOnlyRegistryContract(rawClient); + const { registry, identity } = await getReadOnlyPlaygroundContracts(rawClient); + expect(fromLiveClientMock).toHaveBeenCalledTimes(1); expect(fromLiveClientMock).toHaveBeenCalledWith( { ...cdmJson, registry: EXPECTED_CDM_REGISTRY }, rawClient, EXPECTED_ASSET_DESCRIPTOR, { - libraries: ["@w3s/playground-registry"], + libraries: ["@w3s/playground-registry", "@w3s/playground-identity"], defaultOrigin: READ_ONLY_ORIGIN, }, ); const [, , , options] = fromLiveClientMock.mock.calls[0]; expect(options).not.toHaveProperty("defaultSigner"); expect(getContractMock).toHaveBeenCalledWith("@w3s/playground-registry"); + expect(getContractMock).toHaveBeenCalledWith("@w3s/playground-identity"); + expect(registry).toBeDefined(); + expect(identity).toBeDefined(); }); it("throws a clear error when live lookup fails", async () => { fromLiveClientMock.mockRejectedValue(new Error("registry unavailable")); - await expect(getReadOnlyRegistryContract({} as any)).rejects.toThrow(/MetaRegistryFailure/); + await expect(getReadOnlyPlaygroundContracts({} as any)).rejects.toThrow( + /MetaRegistryFailure/, + ); + }); +}); + +describe("single-contract read-only accessors", () => { + // Registry-only and identity-only paths must not resolve (or couple + // their failure to) the other library — a spine registration problem in + // the meta-registry must not take down pure registry reads, and vice + // versa. + it("getReadOnlyRegistryContract resolves ONLY the registry library", async () => { + await getReadOnlyRegistryContract({} as any); + + const [, , , options] = fromLiveClientMock.mock.calls[0]; + expect((options as { libraries: string[] }).libraries).toEqual([ + "@w3s/playground-registry", + ]); + expect(getContractMock).toHaveBeenCalledWith("@w3s/playground-registry"); + expect(getContractMock).not.toHaveBeenCalledWith("@w3s/playground-identity"); + }); + + it("getReadOnlyIdentityContract resolves ONLY the identity library", async () => { + await getReadOnlyIdentityContract({} as any); + + const [, , , options] = fromLiveClientMock.mock.calls[0]; + expect((options as { libraries: string[] }).libraries).toEqual([ + "@w3s/playground-identity", + ]); + expect(getContractMock).toHaveBeenCalledWith("@w3s/playground-identity"); + expect(getContractMock).not.toHaveBeenCalledWith("@w3s/playground-registry"); + }); +}); + +describe("queryMetadataUri", () => { + const registryWith = (res: { success: boolean; value?: unknown }) => ({ + getMetadataUri: { query: vi.fn(async () => res) }, + }); + + it("returns the CID string when the domain is published", async () => { + await expect( + queryMetadataUri(registryWith({ success: true, value: "bafymeta" }), "app.dot"), + ).resolves.toBe("bafymeta"); + }); + + it('maps the ""-tombstone sentinel to null', async () => { + await expect( + queryMetadataUri(registryWith({ success: true, value: "" }), "app.dot"), + ).resolves.toBeNull(); + }); + + it("throws a clear error on a rejected dry-run", async () => { + await expect( + queryMetadataUri(registryWith({ success: false, value: { err: 1 } }), "app.dot"), + ).rejects.toThrow(/failed at dry-run/); + }); + + it("throws on a non-string response instead of leaking it (ABI drift guard)", async () => { + // The old Option shape resurfacing must fail loudly, not flow into a + // gateway URL as "[object Object]". + await expect( + queryMetadataUri( + registryWith({ success: true, value: { isSome: true, value: "x" } }), + "app.dot", + ), + ).rejects.toThrow(/Unrecognized getMetadataUri response/); }); }); diff --git a/src/utils/registry.ts b/src/utils/registry.ts index d9c14d57..3d5ef96d 100644 --- a/src/utils/registry.ts +++ b/src/utils/registry.ts @@ -14,7 +14,9 @@ // limitations under the License. /** - * Playground registry contract access. + * Playground contract access — the app registry and the identity spine + * (`@w3s/playground-identity`, the global "is this account a revealed + * builder?" contract the registry's publish gate delegates to). */ import { ContractManager, type CdmJson } from "@parity/product-sdk-contracts"; @@ -26,6 +28,7 @@ import type { ResolvedSigner } from "./signer.js"; import { getAssetHubDescriptor } from "./descriptors.js"; import { unwrapResult } from "./tx.js"; import { + PLAYGROUND_IDENTITY_CONTRACT, PLAYGROUND_REGISTRY_CONTRACT, suppressReviveTraceNoise, withoutReviveTraceNoise, @@ -77,7 +80,8 @@ const READ_ONLY_QUERY_ORIGIN = ss58Encode(REVIVE_PALLET_PUBLIC_KEY); async function liveManager( rawClient: PolkadotClient, origin: string, - signer?: ResolvedSigner, + signer: ResolvedSigner | undefined, + libraries: string[], ): Promise { const { env, cdmEnvName } = getChainConfig(); const metaRegistry = getRegistryAddress(cdmEnvName); @@ -96,7 +100,7 @@ async function liveManager( return unwrapResult( await withoutReviveTraceNoise(() => ContractManager.fromLiveClient(manifest, rawClient, getAssetHubDescriptor(env), { - libraries: [PLAYGROUND_REGISTRY_CONTRACT], + libraries, defaultOrigin: origin, ...(signer ? { defaultSigner: signer.signer } : {}), }), @@ -117,21 +121,99 @@ async function liveManager( * funded + mapped user signer. */ export async function getRegistryContract(rawClient: PolkadotClient, signer: ResolvedSigner) { - const manager = await liveManager(rawClient, signer.address, signer); + const manager = await liveManager(rawClient, signer.address, signer, [ + PLAYGROUND_REGISTRY_CONTRACT, + ]); return suppressReviveTraceNoise(manager.getContract(PLAYGROUND_REGISTRY_CONTRACT)); } /** - * Get a read-only handle to the registry contract. No signer required; reads - * use `READ_ONLY_QUERY_ORIGIN` as the dry-run origin. Use this from any path - * that only calls `.query()` methods (e.g. `dot mod` listing moddable apps), - * so the command doesn't need the user to be logged in / mapped first. + * Get a read-only handle to the registry contract only. No signer required; + * reads use `READ_ONLY_QUERY_ORIGIN` as the dry-run origin. Use this from + * paths that never touch the identity spine (e.g. e2e fixture readbacks, + * operator listing tools) so a spine-resolution problem in the meta-registry + * can't take down pure registry reads. * - * Do NOT call `.tx()` on the returned contract — there is no signer wired in, - * and `defaultOrigin` is the keyless pallet-revive account, so any submission - * would either crash or be misattributed. + * Do NOT call `.tx()` on the returned contract — there is no signer wired + * in, and `defaultOrigin` is the keyless pallet-revive account, so any + * submission would either crash or be misattributed. (Same caveat for the + * two accessors below.) */ export async function getReadOnlyRegistryContract(rawClient: PolkadotClient) { - const manager = await liveManager(rawClient, READ_ONLY_QUERY_ORIGIN); + const manager = await liveManager(rawClient, READ_ONLY_QUERY_ORIGIN, undefined, [ + PLAYGROUND_REGISTRY_CONTRACT, + ]); return suppressReviveTraceNoise(manager.getContract(PLAYGROUND_REGISTRY_CONTRACT)); } + +/** + * Get a read-only handle to the identity spine only — the builder-reveal + * read (`isVerified`) the identity gate needs. Kept separate from the + * registry accessor so the gate's self-resolve path doesn't pay for (or + * couple itself to) a registry address resolution it never uses. + */ +export async function getReadOnlyIdentityContract(rawClient: PolkadotClient) { + const manager = await liveManager(rawClient, READ_ONLY_QUERY_ORIGIN, undefined, [ + PLAYGROUND_IDENTITY_CONTRACT, + ]); + return suppressReviveTraceNoise(manager.getContract(PLAYGROUND_IDENTITY_CONTRACT)); +} + +/** + * Get read-only handles to BOTH playground contracts — the registry (app + * feed, metadata lookups) and the identity spine (builder-reveal reads) — in + * ONE `fromLiveClient` pass (the per-library lookups run in parallel inside + * the SDK, so this costs no extra latency over resolving either alone). Use + * this from paths that genuinely need both, e.g. `dot mod` (browse + gate). + * Note the pass is atomic: if EITHER library fails to resolve from the + * meta-registry, both handles fail — single-contract paths should use the + * dedicated accessors above. + */ +export async function getReadOnlyPlaygroundContracts(rawClient: PolkadotClient) { + const manager = await liveManager(rawClient, READ_ONLY_QUERY_ORIGIN, undefined, [ + PLAYGROUND_REGISTRY_CONTRACT, + PLAYGROUND_IDENTITY_CONTRACT, + ]); + return { + registry: suppressReviveTraceNoise(manager.getContract(PLAYGROUND_REGISTRY_CONTRACT)), + identity: suppressReviveTraceNoise(manager.getContract(PLAYGROUND_IDENTITY_CONTRACT)), + }; +} + +/** + * Minimal structural view of the one registry read several call sites share. + * Widened to `unknown` on the value so the helper owns the runtime narrowing. + * Exported so callers typechecked WITHOUT the gitignored `.cdm` augmentation + * (CI, the e2e tree) have a named seam to cast the un-augmented + * `Contract` handle through — its string-index methods satisfy + * property access but not structural assignability. + */ +export interface MetadataUriRegistry { + getMetadataUri: { query(domain: string): Promise<{ success: boolean; value?: unknown }> }; +} + +/** + * Read a domain's metadata URI from the registry and decode the contract's + * none/tombstone sentinel in ONE place: the ABI has no Option shape — the + * return is a plain string and `""` means "not published / tombstoned", + * mapped to `null` here. Throws on a rejected dry-run, and on a non-string + * response (bundled-ABI vs live-contract drift) rather than letting a stale + * shape leak into gateway URLs as `[object Object]`. + */ +export async function queryMetadataUri( + registry: MetadataUriRegistry, + domain: string, +): Promise { + const res = await registry.getMetadataUri.query(domain); + if (!res.success) { + throw new Error( + `Registry lookup for "${domain}" failed at dry-run (chain rejected the call)`, + ); + } + if (typeof res.value !== "string") { + throw new Error( + `Unrecognized getMetadataUri response for "${domain}": ${typeof res.value} (bundled ABI vs live contract drift?)`, + ); + } + return res.value || null; +} diff --git a/tools/list-registry-apps.ts b/tools/list-registry-apps.ts index 743734ed..600da9e1 100644 --- a/tools/list-registry-apps.ts +++ b/tools/list-registry-apps.ts @@ -61,6 +61,9 @@ async function main(): Promise { const registry = await getReadOnlyRegistryContract(client.raw.assetHub); const res = await registry.getApps.query(0, 100); + if (!res.success) { + throw new Error(`getApps dry-run was rejected: ${JSON.stringify(res.value)}`); + } const value = res.value as { entries: RegistryEntry[]; total: number }; console.log(`live registry has ${value.total} app(s); inspecting up to 100:\n`); diff --git a/tools/register-e2e-fixtures.ts b/tools/register-e2e-fixtures.ts index 41d26033..393be732 100644 --- a/tools/register-e2e-fixtures.ts +++ b/tools/register-e2e-fixtures.ts @@ -28,7 +28,7 @@ import { parseArgs } from "node:util"; import { destroyConnection } from "../src/utils/connection.js"; import { checkAllowance, ensureAllowance } from "../src/utils/account/allowance.js"; import { publishToPlayground, normalizeDomain } from "../src/utils/deploy/playground.js"; -import { getReadOnlyRegistryContract } from "../src/utils/registry.js"; +import { getReadOnlyRegistryContract, queryMetadataUri } from "../src/utils/registry.js"; import { resolveSigner } from "../src/utils/signer.js"; import { SIGNER, E2E_DOMAINS } from "../e2e/cli/fixtures/accounts.js"; import { destroyTestClient, getTestClient } from "../e2e/cli/helpers/chain.js"; @@ -147,9 +147,13 @@ async function verifyRegistryEntry(domain: string, metadataCid: string): Promise const registry = await getReadOnlyRegistryContract(client.raw.assetHub); for (let attempt = 1; attempt <= REGISTRY_READBACK_ATTEMPTS; attempt++) { - const result = await registry.getMetadataUri.query(domain); - const value = result.value as { isSome?: boolean; value?: string } | undefined; - if (result.success && value?.isSome && value.value === metadataCid) return; + // queryMetadataUri owns the ""-sentinel decode; a rejected dry-run + // throws, which we treat like "not visible yet" and retry. + try { + if ((await queryMetadataUri(registry, domain)) === metadataCid) return; + } catch { + /* retry below */ + } if (attempt < REGISTRY_READBACK_ATTEMPTS) await delay(REGISTRY_READBACK_DELAY_MS); }