From 439ae1c9fbd6b3b82207cb3dfb1dcc0a22f37b6a Mon Sep 17 00:00:00 2001 From: UtkarshBhardwaj007 Date: Tue, 26 May 2026 14:14:03 +0100 Subject: [PATCH] feat(init): claim playground username during setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After auth + account setup land, `dot init` reads the user's playground-registry username via best-block dry-run. If one's already claimed it gets surfaced in the top breadcrumb (alongside command, network, version) so it stays visible across the run. If not, a Yes/No picker offers to claim one; selecting Yes opens a text input that validates client-side, dry-runs `isUsernameAvailable` to avoid burning a tx on a taken name, then signs `registry.setUsername`. Pinned gas + storage-deposit limits mirror playground-app's known-good values to avoid `Revive.OutOfGas` on first-time storage inserts. Validation mirrors the contract's `validate_username` byte-for-byte: 3-30 chars, ASCII `a-z` + `0-9` + `-`, no leading/trailing hyphen, no double-dash. Enforced at three layers (input validator, prompt submit, and `setRegistryUsername` itself so no public caller can ever push invalid input on-chain). Identity block collapsed to one `account in use` row showing derivation slug + H160 + SS58 (the two earlier rows were the same account in two encodings and read as "two accounts"). Header drops the decorative `polkadot playground` subtitle in `dot init` to make room for the username, and adds `wrap="truncate-end"` to breadcrumb pieces as a defensive cap so a 30-char username on a narrow terminal clips with `…` instead of wrapping into garbage. The `UsernamePrompt.SubmitUsername` step explicitly owns and tears down its session-signer adapter on every exit path: `getSessionSigner()` is not memoised, so each call spins up a fresh terminal-adapter WebSocket. Init also drains the shared `getConnection()` in a finally block on command exit; init runs with `hardExit: false`, so a stray WS would hang the process after "setup complete". --- .changeset/set-username-in-init.md | 15 ++ src/commands/init/IdentityLines.tsx | 90 ++-------- src/commands/init/InitScreen.tsx | 22 ++- src/commands/init/UsernamePrompt.tsx | 254 +++++++++++++++++++++++++++ src/commands/init/completion.test.ts | 25 ++- src/commands/init/completion.ts | 9 +- src/commands/init/index.ts | 14 +- src/utils/ui/theme/Header.tsx | 23 ++- src/utils/username.test.ts | 197 ++++++++++++++++++++- src/utils/username.ts | 156 +++++++++++++++- 10 files changed, 719 insertions(+), 86 deletions(-) create mode 100644 .changeset/set-username-in-init.md create mode 100644 src/commands/init/UsernamePrompt.tsx diff --git a/.changeset/set-username-in-init.md b/.changeset/set-username-in-init.md new file mode 100644 index 0000000..1791e91 --- /dev/null +++ b/.changeset/set-username-in-init.md @@ -0,0 +1,15 @@ +--- +"playground-cli": minor +--- + +`dot init` now prompts you to claim a playground.dot username when one +isn't already set on the registry. If you accept, the CLI signs a +`setUsername` tx against the registry contract and surfaces the chosen +name in the top breadcrumb alongside the command, network, and version. +Runs that find an existing username read it from the registry (best-block +freshness, same as the playground-app) and skip the prompt — your handle +just shows in the header. + +Declining is non-destructive: pick "No" and `dot init` continues as +before. The choice is not persisted, so re-running `dot init` will prompt +again until a name is claimed. diff --git a/src/commands/init/IdentityLines.tsx b/src/commands/init/IdentityLines.tsx index 999a504..6a099d5 100644 --- a/src/commands/init/IdentityLines.tsx +++ b/src/commands/init/IdentityLines.tsx @@ -13,52 +13,34 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { useEffect, useState } from "react"; import { Row, Section } from "../../utils/ui/theme/index.js"; import type { SessionAddresses } from "../../utils/auth.js"; -import { - formatUsernameLine, - lookupUsername, - lookupRegistryUsername, - type UsernameLookup, -} from "../../utils/username.js"; import { PLAYGROUND_PRODUCT_ID } from "../../config.js"; /** - * Four-row identity block shown after a successful login: + * Two-row identity block shown after a successful login: * * logged in - * username (playground|polkadot) - * account in use playground.dot/0 — - * product account + * account in use playground.dot/0 — · * * `logged in` is the SSO-handshake `rootAccountId` (bare-mnemonic on - * current mobile builds). It is the storage key for the People-parachain - * username lookup. It is NOT the same address mobile shows as "Wallet - * account" on its debug screen — that uses the hard `//wallet` + * current mobile builds). It is NOT the same address mobile shows as + * "Wallet account" on its debug screen — that uses the hard `//wallet` * derivation which the host can't reproduce. * - * `username` follows a two-tier precedence, same first tier as the - * playground-app's `displayNameForAccount`: - * 1. registry username — the handle the user set in the playground-app - * profile (`registry.setUsername`), keyed on the product H160 since - * that's the `caller()` the contract records. - * 2. People-parachain identity from `lookupUsername` — the chain-wide - * handle, keyed on the root SS58. - * The source ("playground" vs "polkadot") is suffixed so it's obvious - * which surface the user is seeing. If neither resolves, the row falls - * through to `formatUsernameLine`'s `(no username set on chain)`. + * `account in use` is a single row that surfaces all three views of + * the playground product account: the derivation slug, the H160 (what + * Revive sees as `caller()` and what playground-app displays), and the + * SS58 (what subscan / wallet tooling shows). Two earlier rows + * (`account in use` + `product account`) showed the H160 and SS58 + * separately — same key, two encodings — which read as "two accounts" + * to users. Collapsed here. * - * `account in use` surfaces the derivation slug + the H160 that signs - * on the user's behalf, so the user can verify the exact account - * without inspecting the SS58. `product account` keeps the SS58 form - * on its own row. - * - * Both lookups are async, fired in parallel, each cancellable. The - * People-parachain query has a 10s timeout inside `lookupUsername`; - * the registry query degrades silently to `null` on any error - * (`lookupRegistryUsername`) so older deploys without `getUsername` - * fall through to tier 2 without surfacing an error to the user. + * 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 @@ -66,53 +48,15 @@ import { PLAYGROUND_PRODUCT_ID } from "../../config.js"; * and producing a doubly-derived ghost address. */ export function IdentityLines({ addresses }: { addresses: SessionAddresses }) { - const [walletUsername, setWalletUsername] = useState({ kind: "loading" }); - // null means "lookup completed, no registry username set"; undefined means - // "still loading". Display rule: prefer registry > People > fall back to - // the H160 — same precedence the playground-app uses in `displayNameForAccount`. - const [registryUsername, setRegistryUsername] = useState(undefined); - - useEffect(() => { - let cancelled = false; - lookupUsername(addresses.rootAddress).then((result) => { - if (!cancelled) setWalletUsername(result); - }); - lookupRegistryUsername(addresses.productH160 as `0x${string}`).then((result) => { - if (!cancelled) setRegistryUsername(result); - }); - return () => { - cancelled = true; - }; - }, [addresses.rootAddress, addresses.productH160]); - - const usernameLine = registryUsername - ? registryUsername - : registryUsername === undefined - ? "(looking up...)" - : formatUsernameLine(walletUsername); - const usernameTone = registryUsername || walletUsername.kind === "found" ? "default" : "muted"; - const usernameSource = registryUsername - ? "playground" - : walletUsername.kind === "found" - ? "polkadot" - : null; - return (
- -
); } diff --git a/src/commands/init/InitScreen.tsx b/src/commands/init/InitScreen.tsx index a7bf36e..50ed0c7 100644 --- a/src/commands/init/InitScreen.tsx +++ b/src/commands/init/InitScreen.tsx @@ -20,6 +20,7 @@ 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 { computeAllDone } from "./completion.js"; import { VERSION_LABEL } from "../../utils/version.js"; import { getNetworkLabel } from "../../config.js"; @@ -40,6 +41,10 @@ export function InitScreen({ 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, @@ -47,6 +52,7 @@ export function InitScreen({ loggedInAddress: addresses?.productAddress ?? null, depsComplete, accountComplete, + usernameComplete: username !== undefined, }); const handleDepsDone = () => { @@ -61,6 +67,16 @@ export function InitScreen({ 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 init flow can land on + // "setup complete (with errors)" instead of hanging. + if (!success) setUsername(null); + }; + + const handleUsernameDone = (next: string | null) => { + setUsername(next); }; useEffect(() => { @@ -71,8 +87,8 @@ export function InitScreen({
@@ -86,6 +102,10 @@ export function InitScreen({ )} + {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 ( +
+ + 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 init` hangs after "setup + // complete" (init 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 dot init.", + checking: false, + }); + return; + } + try { + await setRegistryUsername(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/init/completion.test.ts b/src/commands/init/completion.test.ts index 45f2a52..9003e78 100644 --- a/src/commands/init/completion.test.ts +++ b/src/commands/init/completion.test.ts @@ -33,6 +33,7 @@ describe("computeAllDone", () => { loggedInAddress: null, depsComplete: false, accountComplete: false, + usernameComplete: false, }), ).toBe(false); }); @@ -45,6 +46,7 @@ describe("computeAllDone", () => { loggedInAddress: null, depsComplete: true, accountComplete: false, + usernameComplete: false, }), ).toBe(true); }); @@ -57,6 +59,7 @@ describe("computeAllDone", () => { loggedInAddress: null, depsComplete: true, accountComplete: false, + usernameComplete: false, }), ).toBe(false); }); @@ -69,6 +72,7 @@ describe("computeAllDone", () => { loggedInAddress: null, depsComplete: true, accountComplete: false, + usernameComplete: false, }), ).toBe(true); }); @@ -81,11 +85,12 @@ describe("computeAllDone", () => { loggedInAddress: "5Gxyz...", depsComplete: true, accountComplete: false, + usernameComplete: true, }), ).toBe(false); }); - it("completes after QR login + account setup both finish", () => { + it("does NOT complete after account setup until the username prompt resolves", () => { expect( computeAllDone({ needsQr: true, @@ -93,11 +98,25 @@ 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 account setup finishes", () => { + it("completes with existing session after both account + username steps finish", () => { expect( computeAllDone({ needsQr: false, @@ -105,6 +124,7 @@ describe("computeAllDone", () => { loggedInAddress: "5Gxyz...", depsComplete: true, accountComplete: true, + usernameComplete: true, }), ).toBe(true); }); @@ -117,6 +137,7 @@ describe("computeAllDone", () => { loggedInAddress: "5Gxyz...", depsComplete: true, accountComplete: false, + usernameComplete: true, }), ).toBe(false); }); diff --git a/src/commands/init/completion.ts b/src/commands/init/completion.ts index f5f5b6e..07572b9 100644 --- a/src/commands/init/completion.ts +++ b/src/commands/init/completion.ts @@ -24,6 +24,13 @@ export interface InitCompletionState { 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: InitCompletionState): boolean { @@ -31,6 +38,6 @@ export function computeAllDone(state: InitCompletionState): boolean { return ( state.depsComplete && state.authResolved && - (needsAccountSetup ? state.accountComplete : true) + (needsAccountSetup ? state.accountComplete && state.usernameComplete : true) ); } diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index e4b0cbd..6c0e35d 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -20,6 +20,7 @@ import { captureWarning, withSpan, errorMessage } from "../../telemetry.js"; import { runCliCommand } from "../../cli-runtime.js"; import { InitScreen } from "./InitScreen.js"; import { connect, type LoginHandle, type SessionAddresses } from "../../utils/auth.js"; +import { destroyConnection } from "../../utils/connection.js"; export const initCommand = new Command("init") .description("Install prerequisites and login via mobile QR") @@ -61,7 +62,18 @@ export const initCommand = new Command("init") onDone: () => app.unmount(), }), ); - await withSpan("cli.init.setup", "run init setup", () => app.waitUntilExit()); + try { + await withSpan("cli.init.setup", "run init setup", () => app.waitUntilExit()); + } finally { + // The init 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. Init runs with `hardExit: false`, so the event + // loop has to drain naturally — leaving the WS open means + // `dot init` hangs after "setup complete". + destroyConnection(); + } console.log(); }), diff --git a/src/utils/ui/theme/Header.tsx b/src/utils/ui/theme/Header.tsx index 1e29cfc..3b868a8 100644 --- a/src/utils/ui/theme/Header.tsx +++ b/src/utils/ui/theme/Header.tsx @@ -26,6 +26,13 @@ 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; /** @@ -44,7 +51,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, right, tabTitle }: HeaderProps) { +export function Header({ cmd, subtitle, network, username, right, tabTitle }: HeaderProps) { const { stdout } = useStdout(); useEffect(() => { @@ -52,7 +59,7 @@ export function Header({ cmd, subtitle, network, right, tabTitle }: HeaderProps) setWindowTitle(title); }, [cmd, subtitle, tabTitle]); - const pieces = [cmd, subtitle, network].filter((p): p is string => Boolean(p)); + const pieces = [cmd, subtitle, network, username].filter((p): p is string => Boolean(p)); const cols = stdout?.columns ?? 80; const width = Math.max(10, Math.min(cols - LAYOUT.leftMargin * 2, LAYOUT.ruleWidthMax)); @@ -67,7 +74,17 @@ export function Header({ cmd, subtitle, network, right, tabTitle }: HeaderProps) {pieces.map((piece, i) => ( {i > 0 && {" · "}} - 0}> + {/* + * wrap="truncate-end" so that an unexpectedly long + * breadcrumb (e.g. a 30-char registry username on + * a narrow terminal) clips with `…` instead of + * wrapping each piece into garbage like + * `dot ini` / `t`. The username is the realistic + * worst-case piece — `validateUsernameClient` + * caps it at 30 chars on the input path, but we + * still defend the header. + */} + 0} wrap="truncate-end"> {piece} diff --git a/src/utils/username.test.ts b/src/utils/username.test.ts index a399b2e..4297da4 100644 --- a/src/utils/username.test.ts +++ b/src/utils/username.test.ts @@ -14,7 +14,13 @@ // limitations under the License. import { beforeEach, describe, expect, it, vi } from "vitest"; -import { formatUsernameLine, type UsernameLookup } from "./username.js"; +import { + describeUsernameValidationError, + formatUsernameLine, + validateUsernameClient, + type UsernameLookup, + type UsernameValidationError, +} from "./username.js"; const ZERO_H160 = "0x0000000000000000000000000000000000000000" as `0x${string}`; @@ -151,11 +157,14 @@ describe("lookupUsername", () => { }); }); -// Mocks for `lookupRegistryUsername`. We don't reuse the lookupUsername mocks -// because that function uses its own polkadot-api client; `lookupRegistryUsername` -// goes through the shared `getConnection()` and the read-only registry contract. +// 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(), @@ -163,6 +172,8 @@ vi.mock("./connection.js", () => ({ vi.mock("./registry.js", () => ({ getReadOnlyRegistryContract: (rawClient: unknown) => mockGetReadOnlyRegistryContract(rawClient), + getRegistryContract: (rawClient: unknown, signer: unknown) => + mockGetRegistryContract(rawClient, signer), })); describe("lookupRegistryUsername", () => { @@ -231,3 +242,181 @@ describe("lookupRegistryUsername", () => { 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 index 4dc5485..8f342a6 100644 --- a/src/utils/username.ts +++ b/src/utils/username.ts @@ -46,8 +46,9 @@ import { createClient } from "polkadot-api"; import { getWsProvider } from "polkadot-api/ws"; import { getChainConfig } from "../config.js"; -import { getReadOnlyRegistryContract } from "./registry.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. @@ -187,3 +188,156 @@ export async function lookupRegistryUsername(productH160: `0x${string}`): Promis 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"); + } +}