From 0e4e03c0564f1c650a2eb9b1a9f40132daef8636 Mon Sep 17 00:00:00 2001 From: Reinhard Hatko Date: Wed, 30 Sep 2026 12:57:57 +0200 Subject: [PATCH 1/3] fix(identity): gate the signer actually in use; say 'Log in first' when not logged in The builder-identity gate always resolved the phone session, so headless runs with --suri / --signer dev and no session were shown 'Join the competition first' and blocked. It now checks the account that will sign when one is chosen explicitly, and a missing login gets its own notice. E2E setup also tops up the deployer's Bulletin allowance (a spent quota surfaced as Invalid.Payment), and the mod test uses the funded deployer. --- .changeset/identity-gate-acting-signer.md | 11 +++++ e2e/cli/mod.test.ts | 6 +-- e2e/cli/setup/global.ts | 13 ++++- src/commands/decentralize/index.ts | 2 +- src/commands/deploy-all/index.ts | 2 +- src/commands/deploy/index.ts | 7 ++- src/commands/mod/index.ts | 18 ++++--- src/commands/shared/gateOrNotice.test.ts | 17 ++++++- src/commands/shared/gateOrNotice.ts | 18 +++++-- src/commands/shared/identityGateCopy.ts | 6 +-- src/utils/identity/identityGate.test.ts | 39 ++++++++++++++- src/utils/identity/identityGate.ts | 60 ++++++++++++++++++----- 12 files changed, 162 insertions(+), 37 deletions(-) create mode 100644 .changeset/identity-gate-acting-signer.md 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/e2e/cli/mod.test.ts b/e2e/cli/mod.test.ts index be6fc217..b128fd1f 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, }); @@ -153,7 +153,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/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 { From 495bde3e0244aca9be131091f1fe8e227fc32ebe Mon Sep 17 00:00:00 2001 From: Reinhard Hatko Date: Thu, 1 Oct 2026 11:10:42 +0200 Subject: [PATCH 2/3] fix(e2e): read the registry through the shared getMetadataUri decoder The E2E getApp helper (and the fixture tool's readback) still narrowed on the pre-v2 Option shape, so every app read as missing: globalSetup re-registered the template on every run in every cell at once (the source of the Invalid.Stale nonce collisions), and deploy tests asserting the registry entry could never pass. Both now use getAppMetadataUri. Two tests still asserted pre-gate behaviour. The no-login mod test and the corrupted-session deploy test now pin the gated outcome ('Log in first', nothing signed), and the latter corrupts the live SsoSessionsV3 file instead of the long-unread SsoSessions name. --- e2e/cli/fixtures/registry.ts | 32 ++++++++++++++------------------ e2e/cli/mod.test.ts | 14 +++++++++----- e2e/cli/session.test.ts | 20 +++++++++++--------- tools/register-e2e-fixtures.ts | 6 +++--- 4 files changed, 37 insertions(+), 35 deletions(-) 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 b128fd1f..96d83019 100644 --- a/e2e/cli/mod.test.ts +++ b/e2e/cli/mod.test.ts @@ -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"); }, ); }); 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/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); } From 3f144fd317545927d2e09122e7d4498db01244a8 Mon Sep 17 00:00:00 2001 From: Reinhard Hatko Date: Thu, 1 Oct 2026 11:11:01 +0200 Subject: [PATCH 3/3] ci(e2e): build cargo-pvm-contract on its pinned toolchain cdm setup runs cargo install from outside the clone, so cargo-pvm-contract's rust-toolchain.toml (1.92) is ignored and the build used the floating nightly. Nightly 1.101 changed never-type fallback (rust#148922) and trie-db stopped compiling, failing pr-deploy-cdm at toolchain install on every run. Read the pinned channel from upstream and build with it; contract builds stay on nightly. --- .github/workflows/e2e.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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