diff --git a/.changeset/dot-init-identity-display.md b/.changeset/dot-init-identity-display.md new file mode 100644 index 0000000..9ce4454 --- /dev/null +++ b/.changeset/dot-init-identity-display.md @@ -0,0 +1,8 @@ +--- +"playground-cli": minor +--- + +`dot init` now shows your username and your product account address alongside the existing "logged in" confirmation. + +- **Username** comes from your on-chain identity on People parachain (`Resources.Consumers` storage). If you haven't registered a username yet you'll see `(no username set on chain)`; if the lookup fails or times out (5s) it falls back to `(lookup failed)`. +- **Product account** is the SS58 + truncated H160 derived locally from your root account via the same sr25519 soft-derivation path that the mobile wallet uses privately. The address you see here is the SAME one `playground-app` resolves for "My apps" and the SAME one your CLI signs as on-chain — so a quick eyeball is enough to confirm both clients agree on your identity. diff --git a/.gitignore b/.gitignore index c67490f..dd5b99e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ .cdm/ docs-internal/ +docs/superpowers/ .worktrees/ reference-repos/ .tokensave diff --git a/src/commands/init/IdentityLines.tsx b/src/commands/init/IdentityLines.tsx new file mode 100644 index 0000000..4a43b86 --- /dev/null +++ b/src/commands/init/IdentityLines.tsx @@ -0,0 +1,64 @@ +// 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 { useEffect, useState } from "react"; +import { Row, Section } from "../../utils/ui/theme/index.js"; +import { formatUsernameLine, lookupUsername, type UsernameLookup } from "../../utils/username.js"; +import { productAccountDisplay } from "./identityLine.js"; + +/** + * Two-line identity block shown after a successful login: + * + * username alice.dot + * product account 5Grwva...utQY (0x1a2b...ef34) + * + * The username lookup is async (queries People parachain) and has a 5s + * timeout inside `lookupUsername`; the product account is synchronous (pure + * sr25519 soft derivation). A `(looking up...)` placeholder renders while + * the lookup is in flight; failures and missing identities fall through to + * the relevant fallback strings from `formatUsernameLine`. + */ +export function IdentityLines({ address }: { address: string }) { + const [username, setUsername] = useState({ kind: "loading" }); + + useEffect(() => { + let cancelled = false; + lookupUsername(address).then((result) => { + if (!cancelled) setUsername(result); + }); + return () => { + cancelled = true; + }; + }, [address]); + + const usernameTone = username.kind === "found" ? "default" : "muted"; + + return ( +
+ + +
+ ); +} diff --git a/src/commands/init/InitScreen.tsx b/src/commands/init/InitScreen.tsx index 5dcf527..1a037f2 100644 --- a/src/commands/init/InitScreen.tsx +++ b/src/commands/init/InitScreen.tsx @@ -17,6 +17,7 @@ import { useState, useEffect } from "react"; import { Box } from "ink"; import { Header, Row, Section } from "../../utils/ui/theme/index.js"; import { DependencyList } from "./DependencyList.js"; +import { IdentityLines } from "./IdentityLines.js"; import { QrLogin } from "./QrLogin.js"; import { AccountSetup } from "./AccountSetup.js"; import { computeAllDone } from "./completion.js"; @@ -82,6 +83,8 @@ export function InitScreen({ )} + {loggedInAddress && } + {loggedInAddress && depsComplete && ( diff --git a/src/commands/init/identityLine.test.ts b/src/commands/init/identityLine.test.ts new file mode 100644 index 0000000..384122f --- /dev/null +++ b/src/commands/init/identityLine.test.ts @@ -0,0 +1,47 @@ +// 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, expect, it } from "vitest"; +import { ss58Encode } from "@parity/product-sdk-address"; +import { productAccountAddresses, productAccountDisplay } from "./identityLine.js"; + +// A deterministic, all-zero root public key gives a stable derived product +// account. The exact bytes don't matter; we only assert that the helper +// produces a non-empty SS58 + valid 0x-prefixed H160 and that the display +// helper renders both in the expected "ss58 (h160)" shape. +const ZERO_ROOT_SS58 = ss58Encode(new Uint8Array(32)); + +describe("productAccountAddresses", () => { + it("derives a non-empty SS58 + a 42-char H160 from a root SS58", () => { + const { ss58, h160 } = productAccountAddresses(ZERO_ROOT_SS58); + expect(typeof ss58).toBe("string"); + expect(ss58.length).toBeGreaterThan(40); + expect(h160).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); + + it("is deterministic for the same root SS58", () => { + const a = productAccountAddresses(ZERO_ROOT_SS58); + const b = productAccountAddresses(ZERO_ROOT_SS58); + expect(a.ss58).toBe(b.ss58); + expect(a.h160).toBe(b.h160); + }); +}); + +describe("productAccountDisplay", () => { + it("renders 'ss58 (h160)' with both addresses truncated", () => { + const display = productAccountDisplay(ZERO_ROOT_SS58); + expect(display).toMatch(/^.+\.\.\..+ \(0x.+\.\.\..+\)$/); + }); +}); diff --git a/src/commands/init/identityLine.ts b/src/commands/init/identityLine.ts new file mode 100644 index 0000000..dc83fa8 --- /dev/null +++ b/src/commands/init/identityLine.ts @@ -0,0 +1,49 @@ +// 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. + +/** + * Pure helpers for the identity block in InitScreen — lifted out of the + * `.tsx` per the repo convention "pure logic that lives inside a `.tsx` + * component should be lifted into a sibling `.ts` file" (see + * `completion.ts` next to `InitScreen.tsx` for the same pattern). + * + * The product-account derivation here uses the local + * `src/utils/productAccountDerivation.ts` mirror; Phase 3 of the + * product-account unification migration will swap that for the canonical + * `@parity/product-sdk-keys` export. Output is byte-identical either way. + */ + +import { deriveH160, ss58Decode, ss58Encode, truncateAddress } from "@parity/product-sdk-address"; +import { PLAYGROUND_PRODUCT_ID } from "../../config.js"; +import { deriveProductAccountPublicKey } from "../../utils/productAccountDerivation.js"; + +export interface ProductAccountAddresses { + ss58: string; + h160: `0x${string}`; +} + +export function productAccountAddresses(rootAccountSs58: string): ProductAccountAddresses { + const { publicKey } = ss58Decode(rootAccountSs58); + const productPubkey = deriveProductAccountPublicKey(publicKey, PLAYGROUND_PRODUCT_ID, 0); + return { + ss58: ss58Encode(productPubkey), + h160: deriveH160(productPubkey), + }; +} + +export function productAccountDisplay(rootAccountSs58: string): string { + const { ss58, h160 } = productAccountAddresses(rootAccountSs58); + return `${truncateAddress(ss58, 6, 4)} (${truncateAddress(h160, 6, 4)})`; +} diff --git a/src/utils/username.test.ts b/src/utils/username.test.ts new file mode 100644 index 0000000..4fe5e98 --- /dev/null +++ b/src/utils/username.test.ts @@ -0,0 +1,55 @@ +// 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, expect, it } from "vitest"; +import { formatUsernameLine, type UsernameLookup } from "./username.js"; + +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...)"); + }); +}); diff --git a/src/utils/username.ts b/src/utils/username.ts new file mode 100644 index 0000000..c5bac1e --- /dev/null +++ b/src/utils/username.ts @@ -0,0 +1,123 @@ +// 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 + * `src/utils/allowances/host.ts` (which mirrors host-papp's RFC-0010 call). + * + * The upstream source we're mirroring lives in + * `@novasamatech/host-papp@0.7.9-4/dist/identity/rpcAdapter.js`; keep this in + * sync if that ever changes pallet/storage names. + */ + +import { AccountId, createClient } from "polkadot-api"; +import { getWsProvider } from "polkadot-api/ws"; +import { getChainConfig } from "../config.js"; + +const LOOKUP_TIMEOUT_MS = 5_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 accCodec = AccountId(); + const accountKey = accCodec.dec(rootAccountSs58); + 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([[accountKey]]) as Promise>, + 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(); + } +}