Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/dot-init-identity-display.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dist/

.cdm/
docs-internal/
docs/superpowers/
.worktrees/
reference-repos/
.tokensave
Expand Down
64 changes: 64 additions & 0 deletions src/commands/init/IdentityLines.tsx
Original file line number Diff line number Diff line change
@@ -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<UsernameLookup>({ 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 (
<Section>
<Row
mark="ok"
label="username"
value={formatUsernameLine(username)}
tone={usernameTone}
/>
<Row
mark="ok"
label="product account"
value={productAccountDisplay(address)}
tone="muted"
/>
</Section>
);
}
3 changes: 3 additions & 0 deletions src/commands/init/InitScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -82,6 +83,8 @@ export function InitScreen({
</Section>
)}

{loggedInAddress && <IdentityLines address={loggedInAddress} />}

<DependencyList onDone={handleDepsDone} />

{loggedInAddress && depsComplete && (
Expand Down
47 changes: 47 additions & 0 deletions src/commands/init/identityLine.test.ts
Original file line number Diff line number Diff line change
@@ -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.+\.\.\..+\)$/);
});
});
49 changes: 49 additions & 0 deletions src/commands/init/identityLine.ts
Original file line number Diff line number Diff line change
@@ -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)})`;
}
55 changes: 55 additions & 0 deletions src/utils/username.test.ts
Original file line number Diff line number Diff line change
@@ -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...)");
});
});
123 changes: 123 additions & 0 deletions src/utils/username.ts
Original file line number Diff line number Diff line change
@@ -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<UsernameLookup> {
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<Array<ConsumerRecord | undefined | null>>,
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();
}
}
Loading