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
9 changes: 9 additions & 0 deletions .changeset/fix-init-identity-lines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"playground-cli": patch
---

Fix the `dot init` identity block:

- Stop double-deriving the product account. The "product account" line previously ran `deriveProductAccountPublicKey` on the already-product-derived SS58, producing a ghost address whose SS58 + H160 didn't match what the playground-app actually uses. Both are now taken straight off the auth-derived pubkey via a shared `derivePlaygroundProductPublicKey` helper, so the signer that signs on-chain and the display the user sees can no longer drift.
- Show the SSO wallet root on the "logged in" line instead of the product account. The product account is on its own row underneath with the full SS58 + H160. The root is also what the username lookup is keyed on.
- Fix the username lookup key. Usernames live at `Resources.Consumers[<rootAccountId>]` on the People parachain; the lookup was previously running against the product account and would never find a match. It now uses the wallet root, matching polkadot-desktop's `useSessionIdentity`.
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ These aren't self-evident from reading the code and have bitten us before. Treat
- **Signer mode selection lives in one file** (`src/utils/deploy/signerMode.ts`). The mainnet rewrite is a single-file swap; keep that boundary clean.
- **`src/utils/account/bulletinTopUp.ts` mirrors bulletin-deploy's internal `attemptTestnetTopUp`** so `dot init` front-loads the dev-funder top-up at setup time rather than waiting for the just-in-time call inside `deploy()`. Both flows no-op once the recipient is ≥ 0.1 PAS, so running them back-to-back doesn't double-transfer. Delete the local mirror only once bulletin-deploy surfaces `attemptTestnetTopUp` at the package root — today it's an internal `DotNS` method.

### Accounts: root, product, and what the mobile app shows

- **`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/init/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[<rootAccountId>]` 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

- **`getSessionSigner()` returns an adapter that keeps the Node event loop alive.** Every caller must invoke the returned `destroy()` when done. Forgetting it manifests as `dot <cmd>` hanging after the work visibly finishes.
Expand Down
40 changes: 26 additions & 14 deletions src/commands/init/IdentityLines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,40 +15,52 @@

import { useEffect, useState } from "react";
import { Row, Section } from "../../utils/ui/theme/index.js";
import type { SessionAddresses } from "../../utils/auth.js";
import { formatUsernameLine, lookupUsername, type UsernameLookup } from "../../utils/username.js";
import { productAccountDisplay } from "./identityLine.js";

/**
* Two-line identity block shown after a successful login:
* Three-line identity block shown after a successful login:
*
* logged in <wallet root SS58>
* username alice.dot
* product account <full ss58> (<full 0x h160>)
* product account <product SS58> (<product 0x H160>)
*
* Both the SS58 and the 0x H160 are printed in full so the user can copy
* them directly. The username lookup is async (queries People parachain)
* and has a 10s 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`.
* `logged in` is the SSO-handshake `rootAccountId` (bare-mnemonic on
* current mobile builds). It is the storage key for the username
* lookup. 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.
*
* `product account` is the playground-scoped account derived via
* `product/playground.dot/0` off the root; this is what signs txs on
* the CLI. 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
* and producing a doubly-derived ghost address.
*
* The username lookup is async (queries People parachain) and has a
* 10s timeout inside `lookupUsername`. A `(looking up...)` placeholder
* renders while the lookup is in flight; failures and missing
* identities fall through to the strings from `formatUsernameLine`.
*/
export function IdentityLines({ address }: { address: string }) {
export function IdentityLines({ addresses }: { addresses: SessionAddresses }) {
const [username, setUsername] = useState<UsernameLookup>({ kind: "loading" });

useEffect(() => {
let cancelled = false;
lookupUsername(address).then((result) => {
lookupUsername(addresses.rootAddress).then((result) => {
if (!cancelled) setUsername(result);
});
return () => {
cancelled = true;
};
}, [address]);
}, [addresses.rootAddress]);

const usernameTone = username.kind === "found" ? "default" : "muted";

return (
<Section>
<Row mark="ok" label="logged in" value={addresses.rootAddress} tone="muted" />
<Row
mark="ok"
label="username"
Expand All @@ -58,7 +70,7 @@ export function IdentityLines({ address }: { address: string }) {
<Row
mark="ok"
label="product account"
value={productAccountDisplay(address)}
value={`${addresses.productAddress} (${addresses.productH160})`}
tone="muted"
/>
</Section>
Expand Down
25 changes: 10 additions & 15 deletions src/commands/init/InitScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,19 @@ import { AccountSetup } from "./AccountSetup.js";
import { computeAllDone } from "./completion.js";
import { VERSION_LABEL } from "../../utils/version.js";
import { getNetworkLabel } from "../../config.js";
import type { LoginHandle } from "../../utils/auth.js";
import type { LoginHandle, SessionAddresses } from "../../utils/auth.js";

export function InitScreen({
login,
existingAddress,
existingAddresses,
onDone,
}: {
login: LoginHandle | null;
existingAddress: string | null;
existingAddresses: SessionAddresses | null;
onDone: () => void;
}) {
const needsQr = login !== null;
const [loggedInAddress, setLoggedInAddress] = useState<string | null>(existingAddress);
const [addresses, setAddresses] = useState<SessionAddresses | null>(existingAddresses);
const [authResolved, setAuthResolved] = useState(!needsQr);
const [depsComplete, setDepsComplete] = useState(false);
const [accountComplete, setAccountComplete] = useState(false);
Expand All @@ -44,7 +44,7 @@ export function InitScreen({
const allDone = computeAllDone({
needsQr,
authResolved,
loggedInAddress,
loggedInAddress: addresses?.productAddress ?? null,
depsComplete,
accountComplete,
});
Expand All @@ -53,8 +53,8 @@ export function InitScreen({
setDepsComplete(true);
};

const handleAuthDone = (address: string | null) => {
if (address) setLoggedInAddress(address);
const handleAuthDone = (next: SessionAddresses | null) => {
if (next) setAddresses(next);
setAuthResolved(true);
};

Expand All @@ -77,18 +77,13 @@ export function InitScreen({
/>

{needsQr && <QrLogin login={login} onDone={handleAuthDone} />}
{!needsQr && existingAddress && (
<Section>
<Row mark="ok" label="logged in" value={existingAddress} tone="muted" />
</Section>
)}

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

<DependencyList onDone={handleDepsDone} />

{loggedInAddress && depsComplete && (
<AccountSetup address={loggedInAddress} onDone={handleAccountDone} />
{addresses && depsComplete && (
<AccountSetup address={addresses.productAddress} onDone={handleAccountDone} />
)}

{allDone && (
Expand Down
26 changes: 22 additions & 4 deletions src/commands/init/QrLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,41 @@

import { useState, useEffect } from "react";
import { Row } from "../../utils/ui/theme/index.js";
import { waitForLogin, type LoginStatus, type LoginHandle } from "../../utils/auth.js";
import {
waitForLogin,
type LoginHandle,
type LoginStatus,
type SessionAddresses,
} from "../../utils/auth.js";

export function QrLogin({
login,
onDone,
}: {
login: LoginHandle;
onDone: (address: string | null) => void;
onDone: (addresses: SessionAddresses | null) => void;
}) {
const [status, setStatus] = useState<LoginStatus>({ step: "waiting" });

useEffect(() => {
waitForLogin(login, setStatus).then(onDone);
// `waitForLogin` resolves with the product-account SS58 string for
// back-compat, but the full `SessionAddresses` bundle only lives on
// the most-recent "success" status update. Snapshot it via
// `setStatus` so we hand the parent the whole triple, not just the
// SS58 — the parent needs `rootAddress` for the username lookup.
waitForLogin(login, setStatus).then(() => {
setStatus((current) => {
onDone(current.step === "success" ? current.addresses : null);
return current;
});
});
}, []);

if (status.step === "success") {
return <Row mark="ok" label="logged in" value={status.address} tone="muted" />;
// The "logged in" row is rendered by IdentityLines (which also
// shows username + product account); QrLogin's success path no
// longer prints its own row to avoid duplicating the address.
return null;
}
if (status.step === "error") {
return <Row mark="fail" label="login failed" value={status.message} tone="danger" />;
Expand Down
55 changes: 0 additions & 55 deletions src/commands/init/identityLine.test.ts

This file was deleted.

44 changes: 0 additions & 44 deletions src/commands/init/identityLine.ts

This file was deleted.

8 changes: 4 additions & 4 deletions src/commands/init/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { render } from "ink";
import { captureWarning, withSpan, errorMessage } from "../../telemetry.js";
import { runCliCommand } from "../../cli-runtime.js";
import { InitScreen } from "./InitScreen.js";
import { connect, type LoginHandle } from "../../utils/auth.js";
import { connect, type LoginHandle, type SessionAddresses } from "../../utils/auth.js";

export const initCommand = new Command("init")
.description("Install prerequisites and login via mobile QR")
Expand All @@ -29,7 +29,7 @@ export const initCommand = new Command("init")
console.log();

let login: LoginHandle | null = null;
let existingAddress: string | null = null;
let existingAddresses: SessionAddresses | null = null;

if (!opts.yes) {
try {
Expand All @@ -39,7 +39,7 @@ export const initCommand = new Command("init")
() => connect(),
);
if (result.kind === "existing") {
existingAddress = result.address;
existingAddresses = result.addresses;
} else {
login = result.login;
console.log(" Scan with the Polkadot mobile app to log in:\n");
Expand All @@ -57,7 +57,7 @@ export const initCommand = new Command("init")
const app = render(
React.createElement(InitScreen, {
login,
existingAddress,
existingAddresses,
onDone: () => app.unmount(),
}),
);
Expand Down
Loading
Loading