diff --git a/.changeset/identity-gate-acting-signer.md b/.changeset/identity-gate-acting-signer.md new file mode 100644 index 00000000..43febe06 --- /dev/null +++ b/.changeset/identity-gate-acting-signer.md @@ -0,0 +1,11 @@ +--- +"playground-cli": patch +--- + +Fix the "Join the competition first" notice shown to people who are simply not +logged in: it now says "Log in first" and points at `playground login`. + +The builder-identity gate now checks the account that will actually sign when +you pass `--suri` or `--signer dev` (and `--suri` on `playground mod`), instead +of always looking for a phone login session. Headless and CI runs with a local +key are no longer blocked for having no session. diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a7d2538c..848772d6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -150,7 +150,16 @@ jobs: fi rustup toolchain install nightly --profile minimal --component rust-src rustup default nightly - curl -fsSL https://raw.githubusercontent.com/paritytech/contract-dependency-manager/main/install.sh | bash + # `cdm setup` builds cargo-pvm-contract with `cargo install` + # from OUTSIDE the clone, so the repo's own rust-toolchain.toml + # is ignored and the build gets our floating nightly. Nightly + # 1.101 (2026-09-28) changed never-type fallback (rust#148922) + # and `trie-db` no longer compiles under it. Build the tool on + # the toolchain its repo pins; contract builds stay on nightly. + pvm_toolchain="$(curl -fsSL https://raw.githubusercontent.com/paritytech/cargo-pvm-contract/main/rust-toolchain.toml | sed -n 's/^channel *= *"\(.*\)"/\1/p')" + test -n "$pvm_toolchain" + rustup toolchain install "$pvm_toolchain" --profile minimal + curl -fsSL https://raw.githubusercontent.com/paritytech/contract-dependency-manager/main/install.sh | RUSTUP_TOOLCHAIN="$pvm_toolchain" bash echo "$HOME/.cdm/bin" >> "$GITHUB_PATH" export PATH="$HOME/.cdm/bin:$PATH" command -v cdm diff --git a/e2e/cli/fixtures/registry.ts b/e2e/cli/fixtures/registry.ts index cb0a1fbd..42472bc2 100644 --- a/e2e/cli/fixtures/registry.ts +++ b/e2e/cli/fixtures/registry.ts @@ -21,6 +21,7 @@ import { getTestClient } from "../helpers/chain.js"; import { getReadOnlyRegistryContract } from "../../../src/utils/registry.js"; +import { getAppMetadataUri } from "../../../src/utils/mod/metadataUri.js"; import { resolveSigner } from "../../../src/utils/signer.js"; import { publishToPlayground } from "../../../src/utils/deploy/playground.js"; import { SIGNER } from "./accounts.js"; @@ -53,25 +54,18 @@ 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.) + * Decodes through `getAppMetadataUri`, the CLI's single `getMetadataUri` + * decoder. The v2 registry returns a plain string (empty = absent); this + * helper used to narrow on a pre-v2 `Option`'s `isSome`, which reads + * `undefined` on every call — so every app looked missing and globalSetup + * re-registered the template on every run, in every cell at once. + * An unexpected shape throws instead of reading as "not found". */ 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 { - domain, - owner: "", - metadataUri: String(tuple.value ?? ""), - }; - } catch { - return null; - } + const registry = await getRegistry(); + const metadataUri = await getAppMetadataUri(registry, domain); + if (metadataUri === null) return null; + return { domain, owner: "", metadataUri }; } /** @@ -129,7 +123,9 @@ export async function getAppCount(): Promise { export async function waitForApp(domain: string, timeoutMs = 30_000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { - const entry = await getApp(domain); + // Tolerate a transient dry-run failure while polling; the final + // timeout still reports the miss. + const entry = await getApp(domain).catch(() => null); if (entry) return entry; await new Promise((r) => setTimeout(r, 2_000)); } diff --git a/e2e/cli/mod.test.ts b/e2e/cli/mod.test.ts index be6fc217..96d83019 100644 --- a/e2e/cli/mod.test.ts +++ b/e2e/cli/mod.test.ts @@ -30,7 +30,7 @@ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node import { join } from "node:path"; import { tmpdir } from "node:os"; import { dot } from "./helpers/dot.js"; -import { ALICE, E2E_TLD } from "./fixtures/accounts.js"; +import { SIGNER, E2E_TLD } from "./fixtures/accounts.js"; import { TEST_DOMAIN } from "./fixtures/templates.js"; const tempDirs: string[] = []; @@ -56,7 +56,7 @@ describe("dot mod — clone", () => { { timeout: 240_000 }, async () => { const cwd = makeTempDir("dot-e2e-mod-cwd-"); - const result = await dot(["mod", TEST_DOMAIN, "--suri", ALICE.suri], { + const result = await dot(["mod", TEST_DOMAIN, "--suri", SIGNER.suri], { cwd, timeout: 240_000, }); @@ -133,17 +133,21 @@ describe("dot mod — clone", () => { ); test( - "exits non-zero for unknown domain with no prior session (mod is signer-less)", + "asks a caller with no login and no --suri to log in first", { timeout: 60_000 }, async () => { const tempHome = makeTempDir("dot-e2e-mod-home-"); const cwd = makeTempDir("dot-e2e-mod-cwd-"); const result = await dot(["mod", `some-app.${E2E_TLD}`], { home: tempHome, cwd, timeout: 60_000 }); - expect(result.exitCode).not.toBe(0); const output = result.stdout + result.stderr; - // dot mod is signer-less — it proceeds directly to the registry lookup. - // An unknown domain produces: App `some-app.${E2E_TLD}` not found in registry - expect(output).toContain("not found in registry"); + // The builder-identity gate has no account to check: a soft block + // (exit 0) that points at `playground login`, before any registry + // lookup. Not the "Join the competition" notice, which is for a + // signed-in user the verifier rejects. + expect(result.exitCode, output).toBe(0); + expect(output).toContain("Log in first"); + expect(output).toContain("playground login"); + expect(output).not.toContain("not found in registry"); }, ); }); @@ -153,7 +157,7 @@ describe("dot mod — registry miss", () => { const cwd = makeTempDir("dot-e2e-mod-unknown-"); const domain = `nonexistent-domain-xyz-12345.${E2E_TLD}`; const result = await dot( - ["mod", domain, "--suri", ALICE.suri], + ["mod", domain, "--suri", SIGNER.suri], { cwd, timeout: 120_000 }, ); const output = result.stdout + result.stderr; diff --git a/e2e/cli/session.test.ts b/e2e/cli/session.test.ts index a2e33b06..f54447f9 100644 --- a/e2e/cli/session.test.ts +++ b/e2e/cli/session.test.ts @@ -54,7 +54,10 @@ describe("session management", () => { }); test("corrupted session file does not produce a valid signer", async () => { - const sessionFile = join(tempHome, ".polkadot-apps", "dot-cli_SsoSessions.json"); + // The live session list (host-papp 0.8.7+ renamed it to V3; the older + // `dot-cli_SsoSessions.json` name is never read, so corrupting it + // tested nothing). + const sessionFile = join(tempHome, ".polkadot-apps", "dot-cli_SsoSessionsV3.json"); writeFileSync(sessionFile, "CORRUPT_DATA_HERE"); const result = await dot( @@ -71,15 +74,14 @@ describe("session management", () => { ], { home: tempHome, timeout: 30_000 }, ); - // Must fail with the deliberate no-session notice. `--signer phone` - // with no valid session surfaces NO_SESSION_NOTICE_BODY from - // src/commands/deploy/signerNotice.ts — match its distinctive text so a - // generic "session" mention in an unrelated stack trace can't satisfy - // this. (Pre-0.9 this path emitted SignerNotAvailableError's "No signer - // available"; the phone path now shows the friendlier guidance.) - expect(result.exitCode).not.toBe(0); + // A corrupt session reads as "no session", and the builder-identity + // gate runs before signer resolution: it has no account to check, so + // it soft-blocks (exit 0) with the log-in notice and nothing is signed. const output = result.stdout + result.stderr; - expect(output).toContain("Mobile (phone) signing needs a logged-in session"); + expect(result.exitCode, output).toBe(0); + expect(output).toContain("Log in first"); + expect(output).toContain("playground login"); + expect(output).not.toContain("Deploy complete"); }); test("build does not create or modify session files", async () => { diff --git a/e2e/cli/setup/global.ts b/e2e/cli/setup/global.ts index 9eeb522a..55a4b40a 100644 --- a/e2e/cli/setup/global.ts +++ b/e2e/cli/setup/global.ts @@ -21,7 +21,8 @@ * 3. Log test accounts */ -import { destroyTestClient } from "../helpers/chain.js"; +import { destroyTestClient, getTestClient } from "../helpers/chain.js"; +import { ensureAllowance } from "../../../src/utils/account/allowance.js"; import { fundDeployerIfLow } from "./fund.js"; import { ensureTemplateRegistered } from "../fixtures/registry.js"; import { SIGNER, BOB } from "../fixtures/accounts.js"; @@ -60,6 +61,16 @@ export async function setup() { } } + // SIGNER also signs the Bulletin metadata `store` for template registration + // and dev-mode deploys. Native PAS is not enough: with no Bulletin + // authorization the store is rejected as `Invalid.Payment` even at a huge + // balance. Soft-fail so a Bulletin hiccup only degrades the tests that use it. + try { + await ensureAllowance(await getTestClient(), SIGNER.address); + } catch (err) { + console.warn(`[e2e setup] Bulletin allowance check failed: ${err}`); + } + // Template registration is only consumed by one test (`dot mod` happy // path), which uses `.skipIf(!TEST_DOMAIN)`. A failure here doesn't // block the rest of the suite — log and continue. diff --git a/src/commands/decentralize/index.ts b/src/commands/decentralize/index.ts index f16659f6..e9782d2b 100644 --- a/src/commands/decentralize/index.ts +++ b/src/commands/decentralize/index.ts @@ -165,7 +165,7 @@ export const decentralizeCommand = new Command("decentralize") // outcome (yellow box, exit 0); release the shared connection we // primed for the read before returning. const conn = await getConnection(); - if (await enforceIdentityGate(conn.raw.assetHub)) { + if (await enforceIdentityGate(conn.raw.assetHub, { suri: opts.suri })) { destroyConnection(); process.exitCode = 0; return; diff --git a/src/commands/deploy-all/index.ts b/src/commands/deploy-all/index.ts index 1c483357..23ad6803 100644 --- a/src/commands/deploy-all/index.ts +++ b/src/commands/deploy-all/index.ts @@ -151,7 +151,7 @@ async function runDeployAll(opts: DeployAllOpts): Promise { // joined the competition may deploy. Runs before signer resolution; // reuses the shared connection. Blocked is a soft outcome (exit 0). const conn = await getConnection(); - if (await enforceIdentityGate(conn.raw.assetHub)) { + if (await enforceIdentityGate(conn.raw.assetHub, { suri: opts.suri, signer: mode })) { cleanupOnce(); process.exitCode = 0; return; diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index 763081e1..f2298a4b 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -173,7 +173,12 @@ export const deployCommand = new Command("deploy") // will use. Blocked is a soft outcome (yellow box, exit 0). try { const conn = await getConnection(); - if (await enforceIdentityGate(conn.raw.assetHub)) { + if ( + await enforceIdentityGate(conn.raw.assetHub, { + suri: opts.suri, + signer: opts.signer, + }) + ) { cleanupOnce(); process.exitCode = 0; return; diff --git a/src/commands/mod/index.ts b/src/commands/mod/index.ts index a6af18c6..ccfd61ca 100644 --- a/src/commands/mod/index.ts +++ b/src/commands/mod/index.ts @@ -45,16 +45,18 @@ interface FetchedAppMetadata { export const modCommand = new Command("mod") .description("Mod a playground app — clone the source as a fresh project to customise") .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 + // `playground mod` never signs: browse + metadata lookups go through // getReadOnlyRegistryContract 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 }) => - runCliCommand("mod", { watchdog: true, hardExit: true }, () => runModCommand(rawDomain)), + // origin. --suri only chooses WHICH account the builder-identity gate + // checks (default: the logged-in phone session), for headless/CI use. + .option("--suri ", "Check builder status for this account instead of the login session") + .action(async (rawDomain: string | undefined, opts: { suri?: string }) => + runCliCommand("mod", { watchdog: true, hardExit: true }, () => + runModCommand(rawDomain, opts.suri), + ), ); -export async function runModCommand(rawDomain: string | undefined): Promise { +export async function runModCommand(rawDomain: string | undefined, suri?: string): Promise { try { const client = await withSpan("cli.mod.connection", "connect to registry chain", () => getConnection(), @@ -67,7 +69,7 @@ export async function runModCommand(rawDomain: string | undefined): Promise ({ +const { checkIdentityGateMock, resolveActingH160Mock, renderNoticeMock } = vi.hoisted(() => ({ checkIdentityGateMock: vi.fn(), + resolveActingH160Mock: vi.fn().mockResolvedValue(undefined), renderNoticeMock: vi.fn().mockResolvedValue(undefined), })); vi.mock("../../utils/identity/identityGate.js", () => ({ checkIdentityGate: checkIdentityGateMock, + resolveActingH160: resolveActingH160Mock, })); vi.mock("./IdentityGateNotice.js", () => ({ @@ -43,6 +45,7 @@ const H160 = "0xbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef" as `0x${string}`; beforeEach(() => { vi.clearAllMocks(); renderNoticeMock.mockResolvedValue(undefined); + resolveActingH160Mock.mockResolvedValue(undefined); }); describe("enforceIdentityGate", () => { @@ -83,6 +86,16 @@ describe("enforceIdentityGate", () => { await enforceIdentityGate(RAW); - expect(checkIdentityGateMock).toHaveBeenCalledWith(RAW); + expect(checkIdentityGateMock).toHaveBeenCalledWith(RAW, {}); + }); + + it("checks the explicitly chosen signer's account instead of the session", async () => { + resolveActingH160Mock.mockResolvedValue(H160); + checkIdentityGateMock.mockResolvedValue({ status: "revealed", productH160: H160 }); + + await enforceIdentityGate(RAW, { suri: "//Bob" }); + + expect(resolveActingH160Mock).toHaveBeenCalledWith({ suri: "//Bob" }); + expect(checkIdentityGateMock).toHaveBeenCalledWith(RAW, { account: H160 }); }); }); diff --git a/src/commands/shared/gateOrNotice.ts b/src/commands/shared/gateOrNotice.ts index 87b93f0f..df19707b 100644 --- a/src/commands/shared/gateOrNotice.ts +++ b/src/commands/shared/gateOrNotice.ts @@ -15,7 +15,11 @@ import type { PolkadotClient } from "polkadot-api"; import { withSpan } from "../../telemetry.js"; -import { checkIdentityGate } from "../../utils/identity/identityGate.js"; +import { + checkIdentityGate, + resolveActingH160, + type ActingAs, +} from "../../utils/identity/identityGate.js"; import { renderIdentityGateNotice } from "./IdentityGateNotice.js"; /** @@ -26,10 +30,14 @@ import { renderIdentityGateNotice } from "./IdentityGateNotice.js"; * its own cleanup. Returns `false` when the user is a revealed builder and the * command may proceed; nothing is printed on that path (no flash on success). */ -export async function enforceIdentityGate(rawAssetHubClient: PolkadotClient): Promise { - const result = await withSpan("cli.identity-gate", "check builder identity", () => - checkIdentityGate(rawAssetHubClient), - ); +export async function enforceIdentityGate( + rawAssetHubClient: PolkadotClient, + acting?: ActingAs, +): Promise { + const result = await withSpan("cli.identity-gate", "check builder identity", async () => { + const account = await resolveActingH160(acting); + return checkIdentityGate(rawAssetHubClient, account ? { account } : {}); + }); if (result.status === "revealed") return false; await renderIdentityGateNotice(result.status); return true; diff --git a/src/commands/shared/identityGateCopy.ts b/src/commands/shared/identityGateCopy.ts index 6c7815bf..f0a28036 100644 --- a/src/commands/shared/identityGateCopy.ts +++ b/src/commands/shared/identityGateCopy.ts @@ -30,11 +30,11 @@ export function identityGateCopy(status: BlockedIdentityStatus): GateNoticeCopy switch (status) { case "not-logged-in": return { - title: "Join the competition first", + title: "Log in first", lines: [ - "Playground commands are for builders who've joined the competition, so you need to be signed in first.", + "You're not signed in yet. Playground commands are for builders who've joined the competition, and we need to know who you are to check.", "", - "Run `playground login` and scan the QR code with your Polkadot mobile app, then become a builder and join the competition at playground.dot in your desktop app.", + "Run `playground login` and scan the QR code with your Polkadot mobile app. If you haven't yet, become a builder and join the competition at playground.dot in your desktop app.", ], }; case "anonymous": diff --git a/src/utils/identity/identityGate.test.ts b/src/utils/identity/identityGate.test.ts index 09765cc7..84be0560 100644 --- a/src/utils/identity/identityGate.test.ts +++ b/src/utils/identity/identityGate.test.ts @@ -32,7 +32,9 @@ vi.mock("../registry.js", () => ({ getVerifierContract: getVerifierContractMock, })); -import { checkIdentityGate } from "./identityGate.js"; +import { checkIdentityGate, resolveActingH160 } from "./identityGate.js"; +import { ss58ToH160 } from "@parity/product-sdk-address"; +import { DEV_PUBLISH_ADDRESS } from "../deploy/signerMode.js"; const ZERO = ("0x" + "00".repeat(32)) as `0x${string}`; const REVEALED = ("0x" + "11".repeat(32)) as `0x${string}`; @@ -163,3 +165,38 @@ describe("checkIdentityGate", () => { expect(getVerifierContractMock).not.toHaveBeenCalled(); }); }); + +describe("checkIdentityGate with an explicit account", () => { + it("asks the verifier about that account and never touches the session", async () => { + const verifier = fakeVerifier(async () => ({ success: true, value: true })); + getVerifierContractMock.mockResolvedValue(verifier); + + const result = await checkIdentityGate({} as any, { ...FAST, account: H160 }); + + expect(result).toEqual({ status: "revealed", productH160: H160 }); + expect(verifier.isVerified.query).toHaveBeenCalledWith(H160); + expect(findSessionMock).not.toHaveBeenCalled(); + }); + + it("reports anonymous for that account when the verifier rejects it", async () => { + getVerifierContractMock.mockResolvedValue( + fakeVerifier(async () => ({ success: true, value: false })), + ); + + const result = await checkIdentityGate({} as any, { ...FAST, account: H160 }); + + expect(result).toEqual({ status: "anonymous", productH160: H160 }); + }); +}); + +describe("resolveActingH160", () => { + it("defers to the session when no signer was chosen", async () => { + expect(await resolveActingH160(undefined)).toBeUndefined(); + expect(await resolveActingH160({})).toBeUndefined(); + expect(await resolveActingH160({ signer: "phone" })).toBeUndefined(); + }); + + it("uses the dev publish account for --signer dev", async () => { + expect(await resolveActingH160({ signer: "dev" })).toBe(ss58ToH160(DEV_PUBLISH_ADDRESS)); + }); +}); diff --git a/src/utils/identity/identityGate.ts b/src/utils/identity/identityGate.ts index 39c59030..f048d7f2 100644 --- a/src/utils/identity/identityGate.ts +++ b/src/utils/identity/identityGate.ts @@ -35,7 +35,10 @@ */ import type { PolkadotClient } from "polkadot-api"; +import { ss58ToH160 } from "@parity/product-sdk-address"; import { findSession, deriveSessionAddresses } from "../auth.js"; +import { resolveSigner } from "../signer.js"; +import { DEV_PUBLISH_ADDRESS } from "../deploy/signerMode.js"; import { getVerifierContract } from "../registry.js"; export type IdentityGateResult = @@ -68,7 +71,38 @@ export interface PersonhoodVerifier { isVerified: { query(account: `0x${string}`): Promise }; } +/** + * The signer a command was explicitly told to act as. The registry decides + * `require_revealed` on `env::caller()` — the account that actually publishes — + * so when the caller picked a local key the gate must ask about THAT key, not + * about whatever phone session happens to be logged in (there may be none). + */ +export interface ActingAs { + suri?: string; + signer?: "dev" | "phone"; +} + +/** + * H160 of the explicitly chosen local signer, or `undefined` when the command + * acts as the logged-in phone user (the default) and the session decides. + */ +export async function resolveActingH160(acting?: ActingAs): Promise<`0x${string}` | undefined> { + if (!acting) return undefined; + if (acting.suri) { + const resolved = await resolveSigner({ suri: acting.suri }); + try { + return ss58ToH160(resolved.address) as `0x${string}`; + } finally { + resolved.destroy(); + } + } + if (acting.signer === "dev") return ss58ToH160(DEV_PUBLISH_ADDRESS) as `0x${string}`; + return undefined; +} + interface GateOptions { + /** Check this account instead of the phone session's (see {@link ActingAs}). */ + account?: `0x${string}`; /** Dry-run retry budget. Defaults to 2 (a transient RPC blip shouldn't lock out a builder). */ attempts?: number; /** Delay between retries in ms. Defaults to 250. */ @@ -129,18 +163,22 @@ export async function checkIdentityGate( const attempts = Math.max(1, opts.attempts ?? 2); const delayMs = opts.delayMs ?? 250; - const handle = await findSession(); - if (!handle) return { status: "not-logged-in" }; - let productH160: `0x${string}`; - try { - productH160 = (await deriveSessionAddresses(handle.session)).productH160; - } catch (err) { - return { status: "unverifiable", detail: describe(err) }; - } finally { - // The signer is never used here — release the adapter so its WebSocket - // doesn't keep the event loop alive (mirrors `drip`/`status`). - await handle.adapter.destroy().catch(() => {}); + if (opts.account) { + productH160 = opts.account; + } else { + const handle = await findSession(); + if (!handle) return { status: "not-logged-in" }; + + try { + productH160 = (await deriveSessionAddresses(handle.session)).productH160; + } catch (err) { + return { status: "unverifiable", detail: describe(err) }; + } finally { + // The signer is never used here — release the adapter so its WebSocket + // doesn't keep the event loop alive (mirrors `drip`/`status`). + await handle.adapter.destroy().catch(() => {}); + } } try { diff --git a/tools/register-e2e-fixtures.ts b/tools/register-e2e-fixtures.ts index 13455a59..081c4ba1 100644 --- a/tools/register-e2e-fixtures.ts +++ b/tools/register-e2e-fixtures.ts @@ -28,6 +28,7 @@ import { parseArgs } from "node:util"; import { getEnvTld } from "../src/config.js"; import { destroyConnection } from "../src/utils/connection.js"; import { checkAllowance, ensureAllowance } from "../src/utils/account/allowance.js"; +import { getAppMetadataUri } from "../src/utils/mod/metadataUri.js"; import { publishToPlayground, normalizeDomain } from "../src/utils/deploy/playground.js"; import { getReadOnlyRegistryContract } from "../src/utils/registry.js"; import { resolveSigner } from "../src/utils/signer.js"; @@ -151,9 +152,8 @@ 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; + const uri = await getAppMetadataUri(registry, domain).catch(() => null); + if (uri === metadataCid) return; if (attempt < REGISTRY_READBACK_ATTEMPTS) await delay(REGISTRY_READBACK_DELAY_MS); }