diff --git a/.changeset/remove-username.md b/.changeset/remove-username.md new file mode 100644 index 00000000..394eb105 --- /dev/null +++ b/.changeset/remove-username.md @@ -0,0 +1,5 @@ +--- +"playground-cli": patch +--- + +Remove the username step from `playground login`. The flow no longer prompts you to claim a registry handle and no longer displays a username in the header. diff --git a/CLAUDE.md b/CLAUDE.md index 339072d9..b09eb4e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,6 @@ These aren't self-evident from reading the code and have bitten us before. Treat - **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 — our `summit` is cdm-env's `w3s`, paseo passes through as `paseo-next-v2`); `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. - **Adding a network / Summit — the deployment-doc checklist.** A divergence 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 the one-line switch can't merge until the target is genuinely ready. To point a release at Summit: (1) confirm `@parity/cdm-env` ships a non-empty `w3s` registry address (it's `""` through 2.0.5 — `node -e "console.log(require('@parity/cdm-env').getRegistryAddress('w3s'))"`; bump the dep if empty); (2) confirm Summit endpoints still match polkadot-app-deploy's `assets/environments.json` `summit`/`chains[*].endpoints.summit` (the guard enforces this — bump `@parity/polkadot-app-deploy` and update the `SUMMIT` block together if they drift); (3) flip `ACTIVE_TESTNET_ENV` to `"summit"`; (4) `pnpm typecheck && pnpm test` (the guard is the gate). Prerequisites that land upstream BEFORE the CLI switch (not our PR): the Summit CDM meta-registry contract + the playground-registry published to CDM (resolves by name `@w3s/playground-registry`), the Summit DotNS contracts (owned by polkadot-app-deploy's env catalog, keyed by env id), and the `w3s` registry address in `@parity/cdm-env`. Heads-up: cdm-env's `w3s.ipfsGatewayUrl` is `""` and disagrees with environments.json's `summit.ipfs` — we source the gateway from environments.json on purpose, so the guard checks against that. - **Upstream SDK status for Summit (audited June 2026 at the pinned versions — no bumps needed).** `@parity/product-sdk` already ships Summit at the versions we use: `descriptors@0.6.0` exports `summit-asset-hub`/`summit-bulletin`/`summit-individuality`, and `cloud-storage@0.6.0` carries the `summit` network preset (genesis + bulletin). product-sdk calls the network **`summit`**; only `@parity/cdm-env` and this CLI use the key **`w3s`** (that two-name split is why `cdmEnvName` exists — don't "fix" it). The CLI does NOT depend on `@parity/product-sdk-chain-client`, so its preset table is irrelevant here. `@novasamatech/*` (host-papp/statement-store/host-api/sdk-statement, via `@parity/product-sdk-terminal@0.5.0` → host-papp 0.8.7) is fully **env-agnostic**: SSO handshake, statement-store topic/SessionId derivation, and allowance slot-key derivation embed no network constant (genesisHash is a runtime SCALE field, not a baked value), and host-papp's hardcoded `SS_*_ENDPOINTS` People defaults are dead fallbacks because the terminal always forwards `getChainConfig().peopleEndpoints`. Net: the ONLY thing still pending for a Summit switch is `@parity/cdm-env` shipping the non-empty `w3s` registry address — nothing in product-sdk or triangle-js-sdks. -- **Username lookup hits `Resources.Consumers` on the People parachain** (`src/utils/username.ts`). Mirrors `@novasamatech/host-papp`'s `createIdentityRpcAdapter`. Pass the SS58 string directly to `getValues([[ss58]])` — do NOT round-trip through `AccountId().dec(ss58)`. The upstream code only does that because its callers pass `0x…` hex; on an SS58 string `Bytes(32).dec` silently corrupts it into a different 32-byte sequence and the lookup fails opaquely as `(lookup failed)`. ### Deploy / Bulletin @@ -58,7 +57,6 @@ These aren't self-evident from reading the code and have bitten us before. Treat - **`session.rootAccountId` is whatever the mobile app published as `rootUserAccountId` in the SSO handshake.** On current mobile builds (`polkadot-app-android-v2`, see `feature/sso/impl/.../RealSsoHandshakeUseCase.kt:34` → `deriveRootAccount() = derivationPath = null`) it's the bare-mnemonic sr25519 root with no junction. The host-papp SDK does not derive it — it just decodes the 32 bytes from `HandshakeResponseSensitiveData.rootUserAccountId` (`triangle-js-sdks/packages/host-papp/src/sso/auth/scale/handshake.ts:23-27`) and forwards them. If a future mobile release changes the path, our display will silently change with it — the source of truth is the phone, not the CLI. - **The mobile's "Wallet account address" and "Candidate account address" debug rows are NOT reachable from the host.** They're sr25519 of mnemonic + `//wallet` and mnemonic + `//candidate` respectively (`feature/account/impl/.../RealAccountRepository.kt:166-173`, hard junctions). Hard derivations can't be reproduced from a public key, so the CLI never sees those SS58s. Don't try to surface a "wallet address that matches mobile" — it isn't possible without the mnemonic. - **The playground product account is derived by exactly one function** (`src/utils/sessionSigner.ts::derivePlaygroundProductPublicKey`), called by both `createPlaygroundSessionSigner` (signer construction) and `auth.ts::deriveSessionAddresses` (display triple). The math is `deriveProductAccountPublicKey(rootAccountId, "playground.dot", 0)` from `@parity/product-sdk-keys`. Do NOT call `deriveProductAccountPublicKey` (or any helper that wraps it) on an already-product-derived SS58 — that yields a doubly-derived ghost account. The `productAccountDisplay` / `productAccountAddresses` helpers that used to live in `src/commands/login/identityLine.ts` had exactly this bug and were deleted; resist re-introducing them. A frozen-vector regression test in `src/utils/auth.test.ts` (`deriveSessionAddresses` block) locks the pubkey/H160 the playground-app expects. -- **Username storage is keyed on `session.rootAccountId`, not on the product account.** `Resources.Consumers[]` on the People parachain is populated by mobile's `Resources.register_person` call (signed by `//wallet`-derived key, but the storage key is the root). `lookupUsername` MUST be called with `addresses.rootAddress`, not the product SS58. Polkadot-desktop's `useSessionIdentity(session)` does the same — both read off the SSO `rootAccountId`. - **`SessionAddresses` triples are computed once in `auth.ts` and threaded through.** `ConnectResult`, `LoginStatus.success`, and `SessionHandle` all carry the `{ rootAddress, productAddress, productH160 }` bundle. `SessionHandle.address` is kept as a back-compat alias for `addresses.productAddress` because `signer.ts::resolveSigner` spreads the handle into `ResolvedSigner` and downstream deploy code (`signerMode.ts`, `playground.ts`, `registry.ts`, `DeployScreen.tsx`) reads `.address` for the signing key. UI code should prefer `addresses` so the root vs product distinction stays explicit. ### Allowances / session diff --git a/src/commands/login/IdentityLines.tsx b/src/commands/login/IdentityLines.tsx index 6a099d56..ea32952c 100644 --- a/src/commands/login/IdentityLines.tsx +++ b/src/commands/login/IdentityLines.tsx @@ -36,12 +36,6 @@ import { PLAYGROUND_PRODUCT_ID } from "../../config.js"; * separately — same key, two encodings — which read as "two accounts" * to users. Collapsed here. * - * The user's registry username is intentionally NOT rendered here — it - * lives in the top breadcrumb (see `Header`'s `username` prop) so it - * stays visible across every screen in the command. `UsernamePrompt` - * owns the read + write path; this component is purely the address - * pair now. - * * The SS58 + H160 are taken straight off the auth-derived pair so * they never drift — the bug we had previously was running * `deriveProductAccountPublicKey` again on the already-derived SS58 diff --git a/src/commands/login/LoginScreen.tsx b/src/commands/login/LoginScreen.tsx index 4c23ed2e..1a459d3f 100644 --- a/src/commands/login/LoginScreen.tsx +++ b/src/commands/login/LoginScreen.tsx @@ -20,7 +20,6 @@ import { DependencyList } from "./DependencyList.js"; import { IdentityLines } from "./IdentityLines.js"; import { QrLogin } from "./QrLogin.js"; import { AccountSetup } from "./AccountSetup.js"; -import { UsernamePrompt } from "./UsernamePrompt.js"; import { NextSteps } from "./NextStepsCallout.js"; import { computeAllDone } from "./completion.js"; import { VERSION_LABEL } from "../../utils/version.js"; @@ -42,10 +41,6 @@ export function LoginScreen({ const [depsComplete, setDepsComplete] = useState(false); const [accountComplete, setAccountComplete] = useState(false); const [accountOk, setAccountOk] = useState(true); - // `null` ≡ "no username on chain and user declined to set one"; - // `string` ≡ "username known (existing or just-claimed)". - // `undefined` ≡ "prompt has not resolved yet". - const [username, setUsername] = useState(undefined); const allDone = computeAllDone({ needsQr, @@ -53,7 +48,6 @@ export function LoginScreen({ loggedInAddress: addresses?.productAddress ?? null, depsComplete, accountComplete, - usernameComplete: username !== undefined, }); const handleDepsDone = () => { @@ -68,16 +62,6 @@ export function LoginScreen({ const handleAccountDone = (success: boolean) => { setAccountOk(success); setAccountComplete(true); - // Account setup is a prerequisite for setUsername (the tx needs the - // smart-contract allowance + a funded product account). When account - // setup fails we skip the prompt entirely and treat the step as - // resolved-with-no-username so the login flow can land on - // "setup complete (with errors)" instead of hanging. - if (!success) setUsername(null); - }; - - const handleUsernameDone = (next: string | null) => { - setUsername(next); }; useEffect(() => { @@ -86,12 +70,7 @@ export function LoginScreen({ return ( -
+
{needsQr && } @@ -107,10 +86,6 @@ export function LoginScreen({ /> )} - {addresses && accountComplete && accountOk && ( - - )} - {allDone && (
void; -} - -type Phase = - | { kind: "looking-up" } - | { kind: "already-set"; username: string } - | { kind: "ask" } - | { kind: "input"; externalError: string | null; checking: boolean } - | { kind: "submitting"; name: string } - | { kind: "complete"; username: string | null }; - -export function UsernamePrompt({ addresses, onDone }: UsernamePromptProps) { - const [phase, setPhase] = useState({ kind: "looking-up" }); - - // Initial lookup: do we already have a name on file? - useEffect(() => { - let cancelled = false; - lookupRegistryUsername(addresses.productH160 as `0x${string}`).then((existing) => { - if (cancelled) return; - if (existing) { - setPhase({ kind: "already-set", username: existing }); - } else { - setPhase({ kind: "ask" }); - } - }); - return () => { - cancelled = true; - }; - }, [addresses.productH160]); - - // Once we land in a terminal state, notify the parent exactly once. - useEffect(() => { - if (phase.kind === "already-set") onDone(phase.username); - else if (phase.kind === "complete") onDone(phase.username); - }, [phase, onDone]); - - if (phase.kind === "looking-up") { - return ( -
- -
- ); - } - - if (phase.kind === "already-set") { - return ( -
- -
- ); - } - - if (phase.kind === "ask") { - return ( -
- - {USERNAME_XP_BODY} - - - label="Set a username for your playground profile?" - initialIndex={0} - options={[ - { value: "yes", label: "Yes", hint: "claim a handle on the registry" }, - { value: "no", label: "No", hint: "skip for now" }, - ]} - onSelect={(choice) => { - if (choice === "yes") { - setPhase({ kind: "input", externalError: null, checking: false }); - } else { - setPhase({ kind: "complete", username: null }); - } - }} - /> -
- ); - } - - if (phase.kind === "input") { - const submit = async (raw: string) => { - const name = raw.trim().toLowerCase(); - const err = validateUsernameClient(name); - if (err) { - setPhase({ - kind: "input", - externalError: describeUsernameValidationError(err), - checking: false, - }); - return; - } - - // `isUsernameAvailable` returns null on an older contract or any - // RPC blip — degrade gracefully: skip the precheck and let the tx - // decide. Same contract as `lookupRegistryUsername`. - setPhase({ kind: "input", externalError: null, checking: true }); - const available = await isRegistryUsernameAvailable( - name, - addresses.productH160 as `0x${string}`, - ); - if (available === false) { - setPhase({ - kind: "input", - externalError: `"${name}" is already taken. Try a different one.`, - checking: false, - }); - return; - } - - setPhase({ kind: "submitting", name }); - }; - - return ( -
- { - const tag = validateUsernameClient(value.trim().toLowerCase()); - return tag ? describeUsernameValidationError(tag) : null; - }} - externalError={phase.checking ? "checking availability…" : phase.externalError} - onSubmit={submit} - /> -
- ); - } - - if (phase.kind === "submitting") { - return ; - } - - // phase.kind === "complete" - return null; -} - -function SubmitUsername({ - name, - setPhase, -}: { - name: string; - setPhase: (p: Phase) => void; -}) { - useEffect(() => { - let cancelled = false; - (async () => { - // We own this handle (see file-level docstring — `getSessionSigner` - // is not memoised). Capture it locally so the finally block can - // tear down its WebSocket adapter on every exit path. Forgetting - // this leaks the adapter and `dot login` hangs after "setup - // complete" (login runs with `hardExit: false`, so the event loop - // must drain naturally). - const session = await getSessionSigner(); - if (!session) { - if (!cancelled) - setPhase({ - kind: "input", - externalError: "Lost session — re-run playground login.", - checking: false, - }); - return; - } - try { - // `setRegistryUsername` takes a `ResolvedSigner`; a session - // handle is one once tagged with its source (same shape - // `resolveSigner` produces for QR/mobile sessions). - await setRegistryUsername({ ...session, source: "session" }, name); - if (!cancelled) setPhase({ kind: "complete", username: name }); - } catch (err) { - if (cancelled) return; - const msg = err instanceof Error ? err.message : String(err); - setPhase({ - kind: "input", - externalError: `Couldn't save your username: ${msg}`, - checking: false, - }); - } finally { - // Fire-and-forget. `SessionHandle.destroy()` returns void; the - // underlying adapter swallows post-destroy artifacts (the - // process-guard catches anything that leaks through). - session.destroy(); - } - })(); - return () => { - cancelled = true; - }; - }, [name, setPhase]); - - return ( -
- - - - -
- ); -} diff --git a/src/commands/login/completion.test.ts b/src/commands/login/completion.test.ts index 5124cd50..c81cc722 100644 --- a/src/commands/login/completion.test.ts +++ b/src/commands/login/completion.test.ts @@ -33,7 +33,6 @@ describe("computeAllDone", () => { loggedInAddress: null, depsComplete: false, accountComplete: false, - usernameComplete: false, }), ).toBe(false); }); @@ -46,7 +45,6 @@ describe("computeAllDone", () => { loggedInAddress: null, depsComplete: true, accountComplete: false, - usernameComplete: false, }), ).toBe(true); }); @@ -59,7 +57,6 @@ describe("computeAllDone", () => { loggedInAddress: null, depsComplete: true, accountComplete: false, - usernameComplete: false, }), ).toBe(false); }); @@ -72,7 +69,6 @@ describe("computeAllDone", () => { loggedInAddress: null, depsComplete: true, accountComplete: false, - usernameComplete: false, }), ).toBe(true); }); @@ -85,12 +81,11 @@ describe("computeAllDone", () => { loggedInAddress: "5Gxyz...", depsComplete: true, accountComplete: false, - usernameComplete: true, }), ).toBe(false); }); - it("does NOT complete after account setup until the username prompt resolves", () => { + it("completes after QR login + account setup both finish", () => { expect( computeAllDone({ needsQr: true, @@ -98,25 +93,11 @@ describe("computeAllDone", () => { loggedInAddress: "5Gxyz...", depsComplete: true, accountComplete: true, - usernameComplete: false, - }), - ).toBe(false); - }); - - it("completes after QR login + account setup + username step all finish", () => { - expect( - computeAllDone({ - needsQr: true, - authResolved: true, - loggedInAddress: "5Gxyz...", - depsComplete: true, - accountComplete: true, - usernameComplete: true, }), ).toBe(true); }); - it("completes with existing session after both account + username steps finish", () => { + it("completes with existing session after account setup finishes", () => { expect( computeAllDone({ needsQr: false, @@ -124,7 +105,6 @@ describe("computeAllDone", () => { loggedInAddress: "5Gxyz...", depsComplete: true, accountComplete: true, - usernameComplete: true, }), ).toBe(true); }); @@ -137,7 +117,6 @@ describe("computeAllDone", () => { loggedInAddress: "5Gxyz...", depsComplete: true, accountComplete: false, - usernameComplete: true, }), ).toBe(false); }); diff --git a/src/commands/login/completion.ts b/src/commands/login/completion.ts index 55a5a265..355cb60e 100644 --- a/src/commands/login/completion.ts +++ b/src/commands/login/completion.ts @@ -24,13 +24,6 @@ export interface LoginCompletionState { loggedInAddress: string | null; depsComplete: boolean; accountComplete: boolean; - /** - * The username prompt only runs once a session exists AND the account - * setup has succeeded (allowances + funding are prerequisites for the - * `setUsername` tx). When `loggedInAddress` is null we treat this step - * as not applicable, same as `accountComplete`. - */ - usernameComplete: boolean; } export function computeAllDone(state: LoginCompletionState): boolean { @@ -38,6 +31,6 @@ export function computeAllDone(state: LoginCompletionState): boolean { return ( state.depsComplete && state.authResolved && - (needsAccountSetup ? state.accountComplete && state.usernameComplete : true) + (needsAccountSetup ? state.accountComplete : true) ); } diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index d6502714..b94b5c0c 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -66,17 +66,15 @@ export const loginCommand = new Command("login") await withSpan("cli.login.setup", "run login setup", () => app.waitUntilExit()); } finally { // The login flow opens the shared Paseo client lazily via - // `getConnection()` for the registry username lookup - // (`lookupRegistryUsername` in `UsernamePrompt`) and any - // subsequent `setUsername` tx. AccountSetup uses the same - // singleton. Login runs with `hardExit: false`, so the event - // loop has to drain naturally — leaving the WS open means - // `dot login` hangs after "setup complete". + // `getConnection()` (AccountSetup uses the same singleton). + // Login runs with `hardExit: false`, so the event loop has to + // drain naturally — leaving the WS open means `dot login` + // hangs after "setup complete". destroyConnection(); // QR-path login handle: `connect()` transferred adapter // ownership to us (it's the transport `waitForLogin` signs // in over). Once the TUI has exited nothing uses it — - // AccountSetup / UsernamePrompt open their own handles via + // AccountSetup opens its own handles via // `getSessionSigner()` — so release it here, or its // statement-store WebSocket keeps the event loop (and the // process) alive indefinitely. Fire-and-forget + `.catch()` diff --git a/src/commands/login/usernameNotice.test.ts b/src/commands/login/usernameNotice.test.ts deleted file mode 100644 index 2187f4b7..00000000 --- a/src/commands/login/usernameNotice.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { describe, it, expect } from "vitest"; -import { USERNAME_XP_REWARD, USERNAME_XP_TITLE, USERNAME_XP_BODY } from "./usernameNotice.js"; - -describe("username XP notice", () => { - it("has a non-empty title and body", () => { - expect(USERNAME_XP_TITLE.trim()).not.toBe(""); - expect(USERNAME_XP_BODY.trim()).not.toBe(""); - }); - - it("states the XP reward, single-sourced from the constant", () => { - expect(USERNAME_XP_TITLE).toContain(String(USERNAME_XP_REWARD)); - expect(USERNAME_XP_BODY).toContain(String(USERNAME_XP_REWARD)); - expect(USERNAME_XP_BODY.toLowerCase()).toContain("xp"); - expect(USERNAME_XP_BODY.toLowerCase()).toContain("username"); - }); - - // Em dashes read as machine-written; the house copy stays free of them. - it("uses no em dashes", () => { - expect(USERNAME_XP_TITLE).not.toContain("—"); - expect(USERNAME_XP_BODY).not.toContain("—"); - }); -}); diff --git a/src/commands/login/usernameNotice.ts b/src/commands/login/usernameNotice.ts deleted file mode 100644 index 45c49cb4..00000000 --- a/src/commands/login/usernameNotice.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 - -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * Shown above the "set a username?" choice at the end of `playground login`, - * so the user sees the XP incentive before deciding whether to claim a handle. - * Rendered as a green `success` Callout (a reward, not a warning). - */ -export const USERNAME_XP_REWARD = 25; - -export const USERNAME_XP_TITLE = `Earn ${USERNAME_XP_REWARD} XP`; - -export const USERNAME_XP_BODY = - `Setting a username grants you ${USERNAME_XP_REWARD} XP and claims your handle ` + - "for your playground profile."; diff --git a/src/utils/auth.ts b/src/utils/auth.ts index abb3322b..3f3505ba 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -62,11 +62,10 @@ const QR_TIMEOUT_MS = 60_000; * - `rootAddress` — SS58 of `session.rootAccountId`. This is the * `rootUserAccountId` the mobile app sent over the SSO handshake. On * current mobile builds this is the bare-mnemonic sr25519 root (no - * junction). It is what `Resources.Consumers` on the People parachain - * is keyed by, so it's the right input for `lookupUsername`. It is - * NOT the same address the phone shows as "Wallet account" on its - * debug screen — that uses the hard-junction `//wallet` path which - * the host cannot reproduce from a public key alone. + * junction). It is NOT the same address the phone shows as "Wallet + * account" on its debug screen — that uses the hard-junction + * `//wallet` path which the host cannot reproduce from a public key + * alone. * - `productAddress` — SS58 of the playground product account derived * via `product/playground.dot/0` from `rootAccountId`. This is what * actually signs on-chain transactions from the CLI. diff --git a/src/utils/ui/theme/Header.tsx b/src/utils/ui/theme/Header.tsx index 24756186..18c4977f 100644 --- a/src/utils/ui/theme/Header.tsx +++ b/src/utils/ui/theme/Header.tsx @@ -27,13 +27,6 @@ export interface HeaderProps { subtitle?: string; /** Short network label — "paseo" on testnet. */ network?: string; - /** - * The user's playground-registry username, when one is known. Rendered as - * a final left-side breadcrumb piece after `network`. The caller is - * responsible for reading from the registry contract; the header just - * paints whatever string is passed and elides the slot when omitted. - */ - username?: string; /** Right-aligned metadata; most commonly the CLI version. */ right?: string; /** @@ -52,7 +45,7 @@ export interface HeaderProps { * a hairline rule, and — as a side effect — sets the user's terminal tab * title so they can see progress without refocusing the terminal. */ -export function Header({ cmd, subtitle, network, username, right, tabTitle }: HeaderProps) { +export function Header({ cmd, subtitle, network, right, tabTitle }: HeaderProps) { const { stdout } = useStdout(); useEffect(() => { @@ -69,7 +62,7 @@ export function Header({ cmd, subtitle, network, username, right, tabTitle }: He // paddingLeft sits INSIDE the row's width (yoga is border-box), so the // text actually gets width - leftMargin columns. const { pieces, separator } = layoutHeader( - { cmd, subtitle, network, username }, + { cmd, subtitle, network }, right, width - LAYOUT.leftMargin, ); diff --git a/src/utils/ui/theme/headerLayout.test.ts b/src/utils/ui/theme/headerLayout.test.ts index 93eceb67..e97f2a4c 100644 --- a/src/utils/ui/theme/headerLayout.test.ts +++ b/src/utils/ui/theme/headerLayout.test.ts @@ -76,23 +76,6 @@ describe("layoutHeader", () => { expect(leftWidth(layout)).toBeLessThanOrEqual(60 - "v0.28.5".length - RIGHT_GAP_MIN); }); - it("sacrifices the username before the subtitle", () => { - const layout = layoutHeader( - { - cmd: "playground deploy", - subtitle: "myapp.dot", - network: "paseo next v2", - username: "a-thirty-character-username-xx", - }, - "v0.28.5", - 72, - ); - const [, subtitle, , username] = layout.pieces; - expect(subtitle).toBe("myapp.dot"); - expect(username).toContain("…"); - expect(leftWidth(layout)).toBeLessThanOrEqual(72 - "v0.28.5".length - RIGHT_GAP_MIN); - }); - it("uses the full width when there is no right label", () => { const layout = layoutHeader( { @@ -108,24 +91,6 @@ describe("layoutHeader", () => { expect(layout.pieces[1]).toBe("devsignerutkplayground.dot"); }); - it("keeps the gap even when both username and subtitle hit the floor", () => { - const layout = layoutHeader( - { - cmd: "playground deploy", - subtitle: "a-very-long-domain-name-indeed-yes.dot", - network: "paseo next v2", - username: "a-thirty-character-username-xx", - }, - "v0.28.5", - 70, - ); - // Previously the MIN_PIECE floor left 1 col of overflow and yoga glued - // the version onto the username. The below-floor pass must absorb it. - expect(leftWidth(layout)).toBeLessThanOrEqual(70 - "v0.28.5".length - RIGHT_GAP_MIN); - expect(layout.pieces[0]).toBe("playground deploy"); - expect(layout.pieces[2]).toBe("paseo next v2"); - }); - it("truncates below the floor before clipping cmd (56-col terminal)", () => { const layout = layoutHeader( { diff --git a/src/utils/ui/theme/headerLayout.ts b/src/utils/ui/theme/headerLayout.ts index 87a2522c..60df7dcc 100644 --- a/src/utils/ui/theme/headerLayout.ts +++ b/src/utils/ui/theme/headerLayout.ts @@ -27,10 +27,10 @@ * Degradation order, mildest first: * 1. narrow the piece separator from " · " to " · " * 2. shrink the gap before the right label from 2 spaces to 1 - * 3. middle-truncate the username, then the subtitle/domain, down to a - * legible floor (middle-truncation keeps the ".dot" suffix visible) - * 4. truncate them below the floor if the row is still too tight - * 5. drop the username, then the subtitle, entirely + * 3. middle-truncate the subtitle/domain down to a legible floor + * (middle-truncation keeps the ".dot" suffix visible) + * 4. truncate it below the floor if the row is still too tight + * 5. drop the subtitle entirely * The cmd and network labels are never cut — they're short and fixed. */ @@ -50,7 +50,6 @@ export interface HeaderParts { cmd: string; subtitle?: string; network?: string; - username?: string; } export interface HeaderLayout { @@ -68,7 +67,7 @@ export function layoutHeader( const budgetAt = (gap: number) => Math.max(0, width - (right ? right.length + gap : 0)); const piecesOf = (p: HeaderParts) => - [p.cmd, p.subtitle, p.network, p.username].filter((v): v is string => Boolean(v)); + [p.cmd, p.subtitle, p.network].filter((v): v is string => Boolean(v)); const widthOf = (p: HeaderParts, separator: string) => piecesOf(p).join(separator).length; const comfortable = budgetAt(RIGHT_GAP); @@ -79,10 +78,10 @@ export function layoutHeader( const tight = budgetAt(RIGHT_GAP_MIN); const current: HeaderParts = { ...parts }; - // Username first (least load-bearing), then the subtitle/domain. Two - // truncation passes: down to the legible floor, then — only if the row is - // still too tight — below it. - const shrinkable: Array<"username" | "subtitle"> = ["username", "subtitle"]; + // The subtitle/domain is the only squeezable piece. Two truncation passes: + // down to the legible floor, then — only if the row is still too tight — + // below it. + const shrinkable: Array<"subtitle"> = ["subtitle"]; for (const floor of [MIN_PIECE, ABS_MIN_PIECE]) { for (const key of shrinkable) { const value = current[key]; diff --git a/src/utils/username.test.ts b/src/utils/username.test.ts deleted file mode 100644 index 4297da4a..00000000 --- a/src/utils/username.test.ts +++ /dev/null @@ -1,422 +0,0 @@ -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - describeUsernameValidationError, - formatUsernameLine, - validateUsernameClient, - type UsernameLookup, - type UsernameValidationError, -} from "./username.js"; - -const ZERO_H160 = "0x0000000000000000000000000000000000000000" as `0x${string}`; - -describe("formatUsernameLine", () => { - it("returns the full username when present", () => { - const lookup: UsernameLookup = { - kind: "found", - fullUsername: "alice.dot", - liteUsername: "alice", - }; - expect(formatUsernameLine(lookup)).toBe("alice.dot"); - }); - - it("falls back to the lite username when full is null", () => { - const lookup: UsernameLookup = { - kind: "found", - fullUsername: null, - liteUsername: "alice", - }; - expect(formatUsernameLine(lookup)).toBe("alice"); - }); - - it("returns '(no username set on chain)' when the account has no identity", () => { - const lookup: UsernameLookup = { kind: "none" }; - expect(formatUsernameLine(lookup)).toBe("(no username set on chain)"); - }); - - it("returns '(lookup failed)' on any lookup error", () => { - const lookup: UsernameLookup = { - kind: "error", - reason: "endpoint unreachable", - }; - expect(formatUsernameLine(lookup)).toBe("(lookup failed)"); - }); - - it("returns '(looking up...)' while the lookup is pending", () => { - const lookup: UsernameLookup = { kind: "loading" }; - expect(formatUsernameLine(lookup)).toBe("(looking up...)"); - }); -}); - -// Mocks must be set up at module-load time so the polkadot-api imports inside -// `username.ts` resolve to our stubs. The pattern mirrors `connection.test.ts`. -const mockGetValues = vi.fn(); -const mockCreateClient = vi.fn(); -const mockGetWsProvider = vi.fn(); -const mockDestroy = vi.fn(); - -vi.mock("polkadot-api", () => ({ - createClient: (provider: unknown) => mockCreateClient(provider), -})); - -vi.mock("polkadot-api/ws", () => ({ - getWsProvider: (endpoints: unknown) => mockGetWsProvider(endpoints), -})); - -describe("lookupUsername", () => { - beforeEach(() => { - vi.resetModules(); - mockGetValues.mockReset(); - mockCreateClient.mockReset(); - mockGetWsProvider.mockReset(); - mockDestroy.mockReset(); - - mockGetWsProvider.mockImplementation(() => ({})); - mockCreateClient.mockImplementation(() => ({ - destroy: mockDestroy, - getUnsafeApi: () => ({ - query: { - Resources: { - Consumers: { - getValues: mockGetValues, - }, - }, - }, - }), - })); - }); - - // Regression guard: under scale-ts's `fromHex`-based string decoder, - // routing the SS58 through `AccountId().dec(...)` silently corrupts it - // (most SS58 chars aren't in `HEX_MAP`) and the storage call surfaces as - // `(lookup failed)`. The whole bug class disappears as long as we pass - // the SS58 string through unchanged — this test fails if anyone - // reintroduces a codec round-trip. - it("passes the SS58 string directly to getValues, with no codec round-trip", async () => { - const ss58 = "5GGpUaN7XNaUp3nEVDPBSR4SQLxFxQsiPHbFwf69Apr3HgDZ"; - mockGetValues.mockResolvedValue([null]); - - const { lookupUsername } = await import("./username.js"); - const result = await lookupUsername(ss58); - - expect(mockGetValues).toHaveBeenCalledTimes(1); - expect(mockGetValues).toHaveBeenCalledWith([[ss58]]); - expect(result).toEqual({ kind: "none" }); - }); - - it("returns 'found' with decoded usernames when the chain has a record", async () => { - const fullUsername = new TextEncoder().encode("alice.dot"); - const liteUsername = new TextEncoder().encode("alice"); - mockGetValues.mockResolvedValue([ - { full_username: fullUsername, lite_username: liteUsername, credibility: null }, - ]); - - const { lookupUsername } = await import("./username.js"); - const result = await lookupUsername("5GGpUaN7XNaUp3nEVDPBSR4SQLxFxQsiPHbFwf69Apr3HgDZ"); - - expect(result).toEqual({ - kind: "found", - fullUsername: "alice.dot", - liteUsername: "alice", - }); - }); - - it("returns 'error' if the Resources.Consumers storage entry is missing from chain metadata", async () => { - mockCreateClient.mockImplementation(() => ({ - destroy: mockDestroy, - getUnsafeApi: () => ({ - query: { Resources: undefined }, - }), - })); - - const { lookupUsername } = await import("./username.js"); - const result = await lookupUsername("5GGpUaN7XNaUp3nEVDPBSR4SQLxFxQsiPHbFwf69Apr3HgDZ"); - - expect(result.kind).toBe("error"); - }); - - it("destroys the per-call client to release the WebSocket", async () => { - mockGetValues.mockResolvedValue([null]); - const { lookupUsername } = await import("./username.js"); - await lookupUsername("5GGpUaN7XNaUp3nEVDPBSR4SQLxFxQsiPHbFwf69Apr3HgDZ"); - expect(mockDestroy).toHaveBeenCalledTimes(1); - }); -}); - -// Mocks for `lookupRegistryUsername` / `isRegistryUsernameAvailable` / -// `setRegistryUsername`. We don't reuse the `lookupUsername` mocks because -// that function uses its own polkadot-api client; the registry-facing helpers -// all go through the shared `getConnection()` and the read-only / signed -// registry contracts. -const mockGetConnection = vi.fn(); -const mockGetReadOnlyRegistryContract = vi.fn(); -const mockGetRegistryContract = vi.fn(); - -vi.mock("./connection.js", () => ({ - getConnection: () => mockGetConnection(), -})); - -vi.mock("./registry.js", () => ({ - getReadOnlyRegistryContract: (rawClient: unknown) => mockGetReadOnlyRegistryContract(rawClient), - getRegistryContract: (rawClient: unknown, signer: unknown) => - mockGetRegistryContract(rawClient, signer), -})); - -describe("lookupRegistryUsername", () => { - beforeEach(() => { - vi.resetModules(); - mockGetConnection.mockReset(); - mockGetReadOnlyRegistryContract.mockReset(); - - // Default: a connection whose raw.assetHub is just a sentinel object — - // we never touch it directly, only forward it to the contract factory. - mockGetConnection.mockResolvedValue({ raw: { assetHub: { _sentinel: "assetHub" } } }); - }); - - // Regression guard for the v7-deploy degradation path. The CLI ships against - // the latest manifest but a target chain may still be running an older - // contract that has no `getUsername` method — the SDK returns a registry - // handle whose `.getUsername` is undefined. We must NOT throw; the row falls - // through to the People-parachain name. - it("returns null when the registry has no getUsername method (older contract)", async () => { - mockGetReadOnlyRegistryContract.mockResolvedValue({}); // no getUsername key at all - const { lookupRegistryUsername } = await import("./username.js"); - await expect(lookupRegistryUsername(ZERO_H160)).resolves.toBeNull(); - }); - - it("returns null when getUsername exists but .query is undefined", async () => { - mockGetReadOnlyRegistryContract.mockResolvedValue({ getUsername: {} }); - const { lookupRegistryUsername } = await import("./username.js"); - await expect(lookupRegistryUsername(ZERO_H160)).resolves.toBeNull(); - }); - - it("returns null when the query result is success=false", async () => { - mockGetReadOnlyRegistryContract.mockResolvedValue({ - getUsername: { - query: vi.fn().mockResolvedValue({ success: false, value: "ignored" }), - }, - }); - const { lookupRegistryUsername } = await import("./username.js"); - await expect(lookupRegistryUsername(ZERO_H160)).resolves.toBeNull(); - }); - - it("returns null when the returned value is the empty-string sentinel", async () => { - mockGetReadOnlyRegistryContract.mockResolvedValue({ - getUsername: { - query: vi.fn().mockResolvedValue({ success: true, value: "" }), - }, - }); - const { lookupRegistryUsername } = await import("./username.js"); - await expect(lookupRegistryUsername(ZERO_H160)).resolves.toBeNull(); - }); - - it("returns the username on a successful query", async () => { - const queryFn = vi.fn().mockResolvedValue({ success: true, value: "alice" }); - mockGetReadOnlyRegistryContract.mockResolvedValue({ getUsername: { query: queryFn } }); - const h160 = "0xabcdef0123456789abcdef0123456789abcdef01" as `0x${string}`; - - const { lookupRegistryUsername } = await import("./username.js"); - const result = await lookupRegistryUsername(h160); - - expect(result).toBe("alice"); - expect(queryFn).toHaveBeenCalledWith(h160); - }); - - it("swallows thrown errors and returns null (display-time fallback)", async () => { - mockGetReadOnlyRegistryContract.mockRejectedValue(new Error("rpc went poof")); - const { lookupRegistryUsername } = await import("./username.js"); - await expect(lookupRegistryUsername(ZERO_H160)).resolves.toBeNull(); - }); -}); - -describe("validateUsernameClient", () => { - // Each case mirrors a branch of the contract's `validate_username` (see - // `playground-app/contracts/registry/lib.rs:260`). If the contract bounds - // ever move, these tests should be the first thing to fail. - it.each<[string, UsernameValidationError | null]>([ - ["al", "UsernameTooShort"], // 2 chars < MIN 3 - ["a".repeat(31), "UsernameTooLong"], // 31 chars > MAX 30 - ["-alice", "UsernameInvalidEdge"], - ["alice-", "UsernameInvalidEdge"], - ["al!ce", "UsernameInvalidChar"], // bang is outside a-z 0-9 - - ["foo bar", "UsernameInvalidChar"], // space is outside a-z 0-9 - - ["al--ice", "UsernameDoubleDash"], - ["alice", null], - ["alice-bob", null], - ["abc123", null], - ["a-1-b-2", null], - ])("validates %s as %s", (input, expected) => { - expect(validateUsernameClient(input)).toBe(expected); - }); - - // The contract lowercases server-side; we lowercase client-side so users - // typing `Alice` see the same a-z rule applied as the chain. - it("lowercases the input before charset checks", () => { - expect(validateUsernameClient("ALICE")).toBeNull(); - expect(validateUsernameClient("Alice-Bob")).toBeNull(); - }); - - it("describeUsernameValidationError returns user-facing copy for every tag", () => { - const tags: UsernameValidationError[] = [ - "UsernameTooShort", - "UsernameTooLong", - "UsernameInvalidChar", - "UsernameInvalidEdge", - "UsernameDoubleDash", - ]; - for (const tag of tags) { - const copy = describeUsernameValidationError(tag); - expect(copy.length).toBeGreaterThan(0); - // No raw revert tags should leak into the user-facing copy. - expect(copy).not.toContain("Username"); - } - }); -}); - -describe("isRegistryUsernameAvailable", () => { - beforeEach(() => { - vi.resetModules(); - mockGetConnection.mockReset(); - mockGetReadOnlyRegistryContract.mockReset(); - mockGetConnection.mockResolvedValue({ raw: { assetHub: { _sentinel: "assetHub" } } }); - }); - - it("returns null when the contract lacks isUsernameAvailable (older deploy)", async () => { - mockGetReadOnlyRegistryContract.mockResolvedValue({}); - const { isRegistryUsernameAvailable } = await import("./username.js"); - await expect(isRegistryUsernameAvailable("alice", ZERO_H160)).resolves.toBeNull(); - }); - - it("returns null on a non-boolean value (defensive: never assume success.value is bool)", async () => { - mockGetReadOnlyRegistryContract.mockResolvedValue({ - isUsernameAvailable: { - query: vi.fn().mockResolvedValue({ success: true, value: "not-a-bool" }), - }, - }); - const { isRegistryUsernameAvailable } = await import("./username.js"); - await expect(isRegistryUsernameAvailable("alice", ZERO_H160)).resolves.toBeNull(); - }); - - it("returns true when the name is available", async () => { - const queryFn = vi.fn().mockResolvedValue({ success: true, value: true }); - mockGetReadOnlyRegistryContract.mockResolvedValue({ - isUsernameAvailable: { query: queryFn }, - }); - const { isRegistryUsernameAvailable } = await import("./username.js"); - await expect(isRegistryUsernameAvailable("alice", ZERO_H160)).resolves.toBe(true); - expect(queryFn).toHaveBeenCalledWith("alice", ZERO_H160); - }); - - it("returns false when the name is already taken", async () => { - mockGetReadOnlyRegistryContract.mockResolvedValue({ - isUsernameAvailable: { - query: vi.fn().mockResolvedValue({ success: true, value: false }), - }, - }); - const { isRegistryUsernameAvailable } = await import("./username.js"); - await expect(isRegistryUsernameAvailable("alice", ZERO_H160)).resolves.toBe(false); - }); -}); - -describe("setRegistryUsername", () => { - beforeEach(() => { - vi.resetModules(); - mockGetConnection.mockReset(); - mockGetRegistryContract.mockReset(); - mockGetConnection.mockResolvedValue({ raw: { assetHub: { _sentinel: "assetHub" } } }); - }); - - // The signer payload itself isn't introspected by this helper — we just - // forward it to `getRegistryContract`. A sentinel object keeps the test - // hermetic without dragging in the real ResolvedSigner shape. - const FAKE_SIGNER = { signer: { _sentinel: "signer" }, address: "5Gxyz", source: "session" }; - - // Defense-in-depth: the UI prompt's own `validate` callback rejects - // invalid input before reaching here, but the helper is publicly exported - // and a future caller could skip the prompt. We refuse outright instead - // of burning a tx that the chain would just revert anyway. - it("refuses to submit a name that fails client-side validation", async () => { - const txFn = vi.fn(); - mockGetRegistryContract.mockResolvedValue({ setUsername: { tx: txFn } }); - - const { setRegistryUsername } = await import("./username.js"); - await expect( - setRegistryUsername( - FAKE_SIGNER as unknown as import("./signer.js").ResolvedSigner, - "al", // 2 chars < min 3 - ), - ).rejects.toThrow(/Invalid username "al"/); - expect(txFn).not.toHaveBeenCalled(); - }); - - it("forwards the name + pinned gas/storage opts to setUsername.tx", async () => { - const txFn = vi.fn().mockResolvedValue({ ok: true }); - mockGetRegistryContract.mockResolvedValue({ setUsername: { tx: txFn } }); - - const { setRegistryUsername } = await import("./username.js"); - await setRegistryUsername( - FAKE_SIGNER as unknown as import("./signer.js").ResolvedSigner, - "alice", - ); - - expect(txFn).toHaveBeenCalledTimes(1); - const [name, opts] = txFn.mock.calls[0]; - expect(name).toBe("alice"); - // Regression guard: if these constants ever change in production code - // it should be a deliberate update — `setUsername` is known to land - // OutOfGas without these pinned values on first-time storage inserts. - expect(opts.gasLimit).toEqual({ ref_time: 1_500_000_000_000n, proof_size: 2_000_000n }); - expect(opts.storageDepositLimit).toBe(1_000_000_000_000n); - }); - - it("throws a helpful error when the contract lacks setUsername (older deploy)", async () => { - mockGetRegistryContract.mockResolvedValue({}); - const { setRegistryUsername } = await import("./username.js"); - await expect( - setRegistryUsername( - FAKE_SIGNER as unknown as import("./signer.js").ResolvedSigner, - "alice", - ), - ).rejects.toThrow(/setUsername is not available/); - }); - - it("throws when the tx dispatch returns ok=false (reverted)", async () => { - mockGetRegistryContract.mockResolvedValue({ - setUsername: { tx: vi.fn().mockResolvedValue({ ok: false }) }, - }); - const { setRegistryUsername } = await import("./username.js"); - await expect( - setRegistryUsername( - FAKE_SIGNER as unknown as import("./signer.js").ResolvedSigner, - "alice", - ), - ).rejects.toThrow(/reverted/); - }); - - it("propagates the underlying error when the tx itself throws (e.g. signer rejection)", async () => { - mockGetRegistryContract.mockResolvedValue({ - setUsername: { tx: vi.fn().mockRejectedValue(new Error("rejected by user")) }, - }); - const { setRegistryUsername } = await import("./username.js"); - await expect( - setRegistryUsername( - FAKE_SIGNER as unknown as import("./signer.js").ResolvedSigner, - "alice", - ), - ).rejects.toThrow(/rejected by user/); - }); -}); diff --git a/src/utils/username.ts b/src/utils/username.ts deleted file mode 100644 index 64d9cb58..00000000 --- a/src/utils/username.ts +++ /dev/null @@ -1,344 +0,0 @@ -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * On-chain username lookup for the user's root account. - * - * The data lives on the People parachain in `Resources.Consumers` (the - * statement-store storage map). `@novasamatech/host-papp` exposes the same - * query inside `createIdentityRpcAdapter`, but the factory is not re-exported - * at the package root and host-papp's `exports` field blocks deep imports - * (see `node_modules/@novasamatech/host-papp/package.json`). Adding host-papp - * as a direct dep just for this one-shot call would also pull in the full - * SSO/sessions/identity-cache pipeline we don't need. So we mirror the small - * piece we do need: the storage query + the byte mapping. Same precedent as - * the RFC-0010 host call, now upstreamed into - * `@parity/product-sdk-terminal/host`. - * - * NOTE on the storage key: `unsafeApi.query.Resources.Consumers.getValues` - * expects the key in JS form — for `AccountId32`, that's an SS58 string. The - * upstream `createIdentityRpcAdapter` runs the input through - * `AccountId().dec(x)` because *its* caller passes a 0x-prefixed pubkey hex - * (see dotli `packages/auth/src/auth.ts`, which calls `getIdentity(\`0x${pk}\`)`), - * so the `.dec` round-trips hex → SS58 before handing it to PAPI. We already - * receive the SS58 string from the QR-login flow, so the `.dec` step would - * silently corrupt it: under the hood `.dec` runs the string through - * scale-ts's `fromHex`, which reads each character via `HEX_MAP[ch]` — most - * SS58 chars (`G`, `H`, `J`, `K`, `P`, `U`, `p`, `r`, …) aren't in the map - * so they coerce to 0 (`undefined << 4 | undefined` → `0`). The resulting - * mostly-zero buffer is then re-encoded by `fromBufferToBase58` into a - * malformed SS58 (wrong length, wrong checksum). That bogus key is what - * gets handed to PAPI's storage encoder, where `getSs58AddressInfo` rejects - * it and the lookup surfaces as `(lookup failed)`. Pass the SS58 directly. - */ - -import { createClient } from "polkadot-api"; -import { getWsProvider } from "polkadot-api/ws"; -import { getChainConfig } from "../config.js"; -import { getReadOnlyRegistryContract, getRegistryContract } from "./registry.js"; -import { getConnection } from "./connection.js"; -import type { ResolvedSigner } from "./signer.js"; - -// Cold-start WS connects to paseo-people-next-system-rpc on a slow conference -// network can take a few seconds before metadata + the first query are ready. -// The success path is sub-second on a fast network; the 10s budget only kicks -// in when the chain is genuinely unreachable. -const LOOKUP_TIMEOUT_MS = 10_000; - -export type UsernameLookup = - | { kind: "loading" } - | { kind: "found"; fullUsername: string | null; liteUsername: string } - | { kind: "none" } - | { kind: "error"; reason: string }; - -export function formatUsernameLine(lookup: UsernameLookup): string { - switch (lookup.kind) { - case "loading": - return "(looking up...)"; - case "found": - return lookup.fullUsername ?? lookup.liteUsername; - case "none": - return "(no username set on chain)"; - case "error": - return "(lookup failed)"; - } -} - -/** - * Raw shape of the `Resources.Consumers` storage value, mirrored from - * `@novasamatech/host-papp/dist/identity/rpcAdapter.js`. Typed as `unknown` - * fields where we only need a few keys; `getUnsafeApi()` returns `any`-ish - * values so we narrow defensively at the read site. - */ -type ConsumerRecord = { - full_username: Uint8Array | null; - lite_username: Uint8Array; - credibility: unknown; -}; - -/** - * Look up the on-chain identity for `rootAccountSs58` with a hard timeout. - * - * Returns within ~5 seconds regardless of network conditions. Slow paths - * return `{ kind: "error", reason: "lookup timed out" }`. The lookup uses - * the People parachain endpoints from `getChainConfig()`. - */ -export async function lookupUsername(rootAccountSs58: string): Promise { - const { peopleEndpoints } = getChainConfig(); - const client = createClient(getWsProvider(peopleEndpoints)); - try { - const unsafeApi = client.getUnsafeApi(); - const query = unsafeApi.query.Resources?.Consumers; - if (!query) { - return { - kind: "error", - reason: "Resources.Consumers storage not found on chain", - }; - } - - const result = await Promise.race([ - query.getValues([[rootAccountSs58]]) as Promise< - Array - >, - new Promise<"timeout">((resolve) => - setTimeout(() => resolve("timeout"), LOOKUP_TIMEOUT_MS), - ), - ]); - - if (result === "timeout") { - return { kind: "error", reason: "lookup timed out" }; - } - - const raw = result[0]; - if (!raw) return { kind: "none" }; - - const textDecoder = new TextDecoder(); - return { - kind: "found", - fullUsername: raw.full_username ? textDecoder.decode(raw.full_username) : null, - liteUsername: textDecoder.decode(raw.lite_username), - }; - } catch (err) { - return { - kind: "error", - reason: err instanceof Error ? err.message : String(err), - }; - } finally { - // Fire-and-forget. The CLI's process-guard catches benign - // post-destroy artifacts from polkadot-api's chainHead unfollow race. - client.destroy(); - } -} - -/** - * Look up the user's playground-registry username (the handle they set in - * the playground-app's profile, NOT the People-parachain identity above). - * - * Keyed on the H160 of the **product account**, because that's the - * `caller()` the on-chain `set_username` records. For phone-mode users - * that's `SessionAddresses.productH160`; for dev / `--suri` flows it's - * the H160 derived from the local signer. Returns `null` for "no - * username set" so callers can fall back to the People-parachain name or - * the H160. Re-uses the shared `getConnection()` client so the lookup - * piggybacks on whatever the calling command already opened, and a - * connection close is the calling code's job. - * - * BACKWARD COMPATIBILITY: the CLI resolves the registry contract via - * `@w3s/playground-registry` (see `contractManifest.ts`). The - * `getUsername` method only exists on v8+; against the older v7 the SDK - * throws `Cannot read properties of undefined (reading 'query')`. We - * catch and return null so `IdentityLines` quietly degrades to the - * People-parachain name. Once a target chain has the v8 contract live - * the call starts returning real values automatically, no CLI release - * needed. - * - * Errors are swallowed (logged via the catch) and reported as `null`: - * this is a display-time enhancement, never a hard failure path. - */ -export async function lookupRegistryUsername(productH160: `0x${string}`): Promise { - try { - const client = await getConnection(); - const registry = await getReadOnlyRegistryContract(client.raw.assetHub); - // The .query property is undefined on older registries → optional chain. - const getUsername = ( - registry as unknown as { - getUsername?: { - query?: (h160: `0x${string}`) => Promise<{ success: boolean; value: unknown }>; - }; - } - ).getUsername; - if (!getUsername?.query) return null; - const res = await getUsername.query(productH160); - if (!res.success) return null; - const value = res.value; - if (typeof value !== "string" || value === "") return null; - return value; - } catch { - return null; - } -} - -// ── Username write path ────────────────────────────────────────────────────── - -/** - * Validation bounds mirrored from the contract's `validate_username` - * (`playground-app/contracts/registry/lib.rs::USERNAME_MIN_LEN/MAX_LEN`). - * Kept as exports so the prompt can render "3–30 characters" copy without - * hardcoding numbers in two places. - */ -export const USERNAME_MIN_LEN = 3; -export const USERNAME_MAX_LEN = 30; - -export type UsernameValidationError = - | "UsernameTooShort" - | "UsernameTooLong" - | "UsernameInvalidChar" - | "UsernameInvalidEdge" - | "UsernameDoubleDash"; - -const VALIDATION_COPY: Record = { - UsernameTooShort: `Use at least ${USERNAME_MIN_LEN} characters.`, - UsernameTooLong: `Keep it under ${USERNAME_MAX_LEN + 1} characters.`, - UsernameInvalidChar: "Only lowercase letters, digits, and hyphens.", - UsernameInvalidEdge: "Cannot start or end with a hyphen.", - UsernameDoubleDash: "No two hyphens in a row.", -}; - -/** - * Client-side mirror of the contract's `validate_username`. Returns the same - * tag the chain would revert with, or `null` on success. Lowercases first so - * a typed `Alice` validates the same way the contract sees it. Mirrors - * `playground-app/src/utils/username.ts::validateUsernameClient` byte-for-byte - * so the CLI and web UI reject the same strings. - */ -export function validateUsernameClient(raw: string): UsernameValidationError | null { - const name = raw.toLowerCase(); - if (name.length < USERNAME_MIN_LEN) return "UsernameTooShort"; - if (name.length > USERNAME_MAX_LEN) return "UsernameTooLong"; - if (name.startsWith("-") || name.endsWith("-")) return "UsernameInvalidEdge"; - let prevDash = false; - for (let i = 0; i < name.length; i++) { - const ch = name.charCodeAt(i); - const ok = - (ch >= 97 && ch <= 122) /* a-z */ || - (ch >= 48 && ch <= 57) /* 0-9 */ || - ch === 45; /* '-' */ - if (!ok) return "UsernameInvalidChar"; - const isDash = ch === 45; - if (isDash && prevDash) return "UsernameDoubleDash"; - prevDash = isDash; - } - return null; -} - -/** Map a validation tag to user-facing copy for inline rendering. */ -export function describeUsernameValidationError(err: UsernameValidationError): string { - return VALIDATION_COPY[err]; -} - -/** - * Pinned gas + storage limits for `setUsername`. The SDK estimator undershoots - * for first-time storage inserts and the tx lands `Revive.OutOfGas`; the - * playground-app went through the same dance (see - * `playground-app/src/AccountPanel.tsx::runTx` for setUsername — same values). - * If the contract's storage shape changes, re-derive via - * `scripts/smoke-test-usernames.ts` in playground-app rather than guessing. - */ -const SET_USERNAME_GAS_LIMIT = { ref_time: 1_500_000_000_000n, proof_size: 2_000_000n }; -const SET_USERNAME_STORAGE_DEPOSIT_LIMIT = 1_000_000_000_000n; - -/** - * Best-block dry-run for the `isUsernameAvailable(name, prospective_caller)` - * predicate. Returns `true` when the lowercased name is unclaimed OR already - * held by `prospectiveCaller` (the contract's self-no-op rule), `false` - * otherwise, and `null` if the lookup itself failed (older contract, RPC - * blip). Callers should treat `null` as "skip the precheck and let the tx - * decide" — same graceful-degradation contract as `lookupRegistryUsername`. - */ -export async function isRegistryUsernameAvailable( - name: string, - prospectiveCaller: `0x${string}`, -): Promise { - try { - const client = await getConnection(); - const registry = await getReadOnlyRegistryContract(client.raw.assetHub); - const fn = ( - registry as unknown as { - isUsernameAvailable?: { - query?: ( - name: string, - caller: `0x${string}`, - ) => Promise<{ success: boolean; value: unknown }>; - }; - } - ).isUsernameAvailable; - if (!fn?.query) return null; - const res = await fn.query(name, prospectiveCaller); - if (!res.success) return null; - return typeof res.value === "boolean" ? res.value : null; - } catch { - return null; - } -} - -/** - * Submit `setUsername(name)` from the user's product account. Returns on the - * first successful dispatch — caller refreshes the displayed username from a - * best-block read afterwards (same pattern as playground-app, which doesn't - * wait for finalization). - * - * Defence-in-depth: even though UI callers pre-validate, we re-run - * `validateUsernameClient` here so no caller (now or future) can ever push an - * invalid name onto the chain. The contract enforces the same rules — but - * burning a tx just to learn we typed `--` is wasteful gas + a confusing UX, - * so we fail fast locally with a readable message instead. - * - * Throws on validation failure, signer rejection, or chain revert. Callers - * are responsible for mapping rejected-by-user to a quiet skip vs. a real - * failure. - */ -export async function setRegistryUsername(signer: ResolvedSigner, name: string): Promise { - const validationError = validateUsernameClient(name); - if (validationError) { - throw new Error( - `Invalid username "${name}": ${describeUsernameValidationError(validationError)}`, - ); - } - const client = await getConnection(); - const registry = await getRegistryContract(client.raw.assetHub, signer); - const setUsername = ( - registry as unknown as { - setUsername?: { - tx?: ( - name: string, - opts?: { - gasLimit?: { ref_time: bigint; proof_size: bigint }; - storageDepositLimit?: bigint; - }, - ) => Promise<{ ok?: boolean }>; - }; - } - ).setUsername; - if (!setUsername?.tx) { - throw new Error("setUsername is not available on this registry deploy"); - } - const res = await setUsername.tx(name, { - gasLimit: SET_USERNAME_GAS_LIMIT, - storageDepositLimit: SET_USERNAME_STORAGE_DEPOSIT_LIMIT, - }); - if (res && res.ok === false) { - throw new Error("setUsername transaction reverted"); - } -}