diff --git a/.changeset/fix-init-identity-lines.md b/.changeset/fix-init-identity-lines.md new file mode 100644 index 0000000..6a0eeaf --- /dev/null +++ b/.changeset/fix-init-identity-lines.md @@ -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[]` 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`. diff --git a/CLAUDE.md b/CLAUDE.md index d33aa11..0aa405c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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[]` 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 ` hanging after the work visibly finishes. diff --git a/src/commands/init/IdentityLines.tsx b/src/commands/init/IdentityLines.tsx index 0600ae3..7b767bc 100644 --- a/src/commands/init/IdentityLines.tsx +++ b/src/commands/init/IdentityLines.tsx @@ -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 * username alice.dot - * product account () + * product account () * - * 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({ 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 (
+
diff --git a/src/commands/init/InitScreen.tsx b/src/commands/init/InitScreen.tsx index 1a037f2..a7bf36e 100644 --- a/src/commands/init/InitScreen.tsx +++ b/src/commands/init/InitScreen.tsx @@ -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(existingAddress); + const [addresses, setAddresses] = useState(existingAddresses); const [authResolved, setAuthResolved] = useState(!needsQr); const [depsComplete, setDepsComplete] = useState(false); const [accountComplete, setAccountComplete] = useState(false); @@ -44,7 +44,7 @@ export function InitScreen({ const allDone = computeAllDone({ needsQr, authResolved, - loggedInAddress, + loggedInAddress: addresses?.productAddress ?? null, depsComplete, accountComplete, }); @@ -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); }; @@ -77,18 +77,13 @@ export function InitScreen({ /> {needsQr && } - {!needsQr && existingAddress && ( -
- -
- )} - {loggedInAddress && } + {addresses && } - {loggedInAddress && depsComplete && ( - + {addresses && depsComplete && ( + )} {allDone && ( diff --git a/src/commands/init/QrLogin.tsx b/src/commands/init/QrLogin.tsx index 982ee43..46f5d02 100644 --- a/src/commands/init/QrLogin.tsx +++ b/src/commands/init/QrLogin.tsx @@ -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({ 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 ; + // 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 ; diff --git a/src/commands/init/identityLine.test.ts b/src/commands/init/identityLine.test.ts deleted file mode 100644 index 2a077e2..0000000 --- a/src/commands/init/identityLine.test.ts +++ /dev/null @@ -1,55 +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, 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"); - // Substrate SS58 addresses for a 32-byte pubkey are 47–48 chars on - // the default ss58Format=42 prefix. Anything shorter would mean - // we'd accidentally re-introduced truncation. - expect(ss58.length).toBeGreaterThanOrEqual(47); - expect(ss58).toMatch(/^[1-9A-HJ-NP-Za-km-z]+$/); - 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 the full SS58 + full 0x-prefixed H160", () => { - const display = productAccountDisplay(ZERO_ROOT_SS58); - const match = display.match(/^([1-9A-HJ-NP-Za-km-z]+) \((0x[0-9a-fA-F]{40})\)$/); - expect(match).not.toBeNull(); - // No ellipses anywhere — the whole point of the change is that we - // print the full address so the user can copy it directly. - expect(display).not.toContain("..."); - }); -}); diff --git a/src/commands/init/identityLine.ts b/src/commands/init/identityLine.ts deleted file mode 100644 index 0bd8672..0000000 --- a/src/commands/init/identityLine.ts +++ /dev/null @@ -1,44 +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. - -/** - * 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). - */ - -import { deriveH160, ss58Decode, ss58Encode } from "@parity/product-sdk-address"; -import { deriveProductAccountPublicKey } from "@parity/product-sdk-keys"; -import { PLAYGROUND_PRODUCT_ID } from "../../config.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 `${ss58} (${h160})`; -} diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index 53daf27..e4b0cbd 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -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") @@ -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 { @@ -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"); @@ -57,7 +57,7 @@ export const initCommand = new Command("init") const app = render( React.createElement(InitScreen, { login, - existingAddress, + existingAddresses, onDone: () => app.unmount(), }), ); diff --git a/src/utils/auth.test.ts b/src/utils/auth.test.ts index 6776691..5547740 100644 --- a/src/utils/auth.test.ts +++ b/src/utils/auth.test.ts @@ -26,10 +26,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { clearLocalAppStorage, + deriveSessionAddresses, waitForLogout, type LogoutHandle, type LogoutStatus, } from "./auth.js"; +import type { UserSession } from "@parity/product-sdk-terminal"; import { DAPP_ID } from "../config.js"; describe("subscribe-before-assignment pattern", () => { @@ -329,3 +331,97 @@ describe("clearLocalAppStorage", () => { await expect(clearLocalAppStorage(dir)).resolves.toBeUndefined(); }); }); + +/** + * `deriveSessionAddresses` is the function this whole branch exists to + * make right. The bug it replaced ran `deriveProductAccountPublicKey` + * twice — once inside `createPlaygroundSessionSigner` and again inside + * `IdentityLines` → `productAccountAddresses` — producing a doubly- + * derived ghost product account whose H160 didn't match what the + * playground-app actually uses for the same root. + * + * These tests lock the contract: + * + * 1. Frozen vectors from a known mnemonic so a regression to a + * doubly-derived shape (or any other algorithm change) fails loud. + * 2. `productAddress !== rootAddress` — the most basic sanity check + * that whatever we display under "product account" can't be + * mistaken for the wallet-root row above it. + * + * The mnemonic used to generate these vectors is + * `train snow there sponsor artwork zebra gossip depth narrow blame + * change private`, a published test wallet — its derivation match + * was verified live against the playground-app's + * `[playground.dot] selected account` log: + * + * address=5GGpUaN7XNaUp3nEVDPBSR4SQLxFxQsiPHbFwf69Apr3HgDZ + * derivedH160=0x47f68a0851a663dfacb4610d673ec708f05576b0 + * + * If any of these change, mobile or product-sdk-keys has moved out + * from under us — that is news, not a test bug to skip. + */ +describe("deriveSessionAddresses", () => { + // SS58 of the bare-mnemonic sr25519 root for the test mnemonic above — + // this is what mobile sends as `rootUserAccountId` in the SSO handshake. + // Bytes captured by deriving the mnemonic with `@polkadot-labs/hdkd`'s + // sr25519CreateDerive(miniSecret)("").publicKey — they MUST be a valid + // ristretto255 point; arbitrary 32-byte buffers won't decode. + const TEST_ROOT_SS58 = "5FZEMcMGTjSveipHTD35RsRtMqZf2wk41g2zAPL8j2UwWTrp"; + const TEST_ROOT_BYTES = Uint8Array.from([ + 0x9a, 0x76, 0x3d, 0x8d, 0x7d, 0xb9, 0x5e, 0xbd, 0xeb, 0x8f, 0xe2, 0x60, 0xb8, 0x90, 0xf3, + 0x5a, 0x25, 0x3d, 0xb8, 0x27, 0x74, 0xf6, 0x34, 0x46, 0x6c, 0xed, 0x38, 0x7a, 0xa1, 0x4e, + 0xfd, 0x29, + ]); + // A second valid sr25519 public key — derived as `//Alice` off the same + // mini-secret. Used only to verify that the H160 moves in lock-step with + // the product SS58 when the root changes. + const ALT_ROOT_BYTES = Uint8Array.from([ + 0xb4, 0x73, 0x69, 0x9f, 0xb2, 0xb2, 0x80, 0x72, 0xe9, 0x25, 0x3e, 0xe6, 0xee, 0x1e, 0x2f, + 0x3c, 0xf4, 0x14, 0xdd, 0x75, 0xae, 0x0f, 0xcc, 0xb1, 0xbf, 0xf9, 0x26, 0x14, 0xf1, 0x7f, + 0x20, 0x7a, + ]); + + function fakeSession(rootBytes: Uint8Array): UserSession { + // `deriveSessionAddresses` only reads `session.rootAccountId`. + // The full UserSession type carries signer callbacks we don't + // exercise, so the cast keeps the test focused. + return { rootAccountId: rootBytes } as unknown as UserSession; + } + + it("matches the playground-app's published product address + H160 for a known root", () => { + const session = fakeSession(TEST_ROOT_BYTES); + const addresses = deriveSessionAddresses(session); + + expect(addresses.rootAddress).toBe(TEST_ROOT_SS58); + expect(addresses.productAddress).toBe("5GGpUaN7XNaUp3nEVDPBSR4SQLxFxQsiPHbFwf69Apr3HgDZ"); + expect(addresses.productH160).toBe("0x47f68a0851a663dfacb4610d673ec708f05576b0"); + }); + + it("returns a product address distinct from the root — guards against double-derivation", () => { + const session = fakeSession(TEST_ROOT_BYTES); + const addresses = deriveSessionAddresses(session); + + // If someone ever re-introduces a productAccountDisplay-style + // helper that takes addresses.productAddress as input and runs + // deriveProductAccountPublicKey on it, the resulting "product" + // SS58 will still be distinct from the root — but it will also + // be distinct from the value this test pins above. The frozen- + // vector test catches that path. This second assertion catches + // the trivial-mistake path: someone making product = root. + expect(addresses.productAddress).not.toBe(addresses.rootAddress); + }); + + it("derives the H160 from the same pubkey as the product SS58", () => { + // Two different rootAccountIds → different product SS58s → and + // the H160 must change in lock-step with the SS58. A regression + // that derived H160 off the root (or off a doubly-derived + // pubkey) would either keep the H160 constant when the root + // moves or break the ss58↔h160 pairing. + const a = deriveSessionAddresses(fakeSession(TEST_ROOT_BYTES)); + const b = deriveSessionAddresses(fakeSession(ALT_ROOT_BYTES)); + + expect(a.productAddress).not.toBe(b.productAddress); + expect(a.productH160).not.toBe(b.productH160); + expect(b.productH160).toMatch(/^0x[0-9a-f]{40}$/); + }); +}); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 37b8941..8aafdbb 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -28,7 +28,7 @@ import type { Dirent } from "node:fs"; import { readdir, unlink } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -import { ss58Encode } from "@parity/product-sdk-address"; +import { deriveH160, ss58Encode } from "@parity/product-sdk-address"; import { createTerminalAdapter, waitForSessions, @@ -44,11 +44,38 @@ import { TERMINAL_METADATA_URL, getChainConfig, } from "../config.js"; -import { createPlaygroundSessionSigner } from "./sessionSigner.js"; +import { + createPlaygroundSessionSigner, + derivePlaygroundProductPublicKey, +} from "./sessionSigner.js"; /** How long we wait for the statement store to publish the pairing QR. */ const QR_TIMEOUT_MS = 60_000; +/** + * The three addresses we surface from a paired session. + * + * - `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. + * - `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. + * - `productH160` — the same product pubkey rendered as a 20-byte EVM + * address (for the Revive / contracts view). Derived from the SAME + * pubkey as `productAddress`; the two MUST stay in lock-step. + */ +export interface SessionAddresses { + rootAddress: string; + productAddress: string; + productH160: `0x${string}`; +} + function createAdapter(): TerminalAdapter { return createTerminalAdapter({ appId: DAPP_ID, @@ -64,12 +91,37 @@ function createPlaygroundSigner(session: UserSession): PolkadotSigner { }); } -function sessionSigningAddress(session: UserSession): string { - return ss58Encode(createPlaygroundSigner(session).publicKey); +/** + * Compute the three display addresses from a paired session. + * + * Shares `derivePlaygroundProductPublicKey` with `createPlaygroundSessionSigner` + * so the signer used for signing and the display SS58/H160 are computed by + * exactly one function. Re-running `deriveProductAccountPublicKey` on the + * SS58 we just produced (the previous `productAccountAddresses` helper did + * exactly this) silently double-derives and yields a ghost address — that + * was the bug this refactor exists to prevent. + * + * Exported for tests; `IdentityLines` reads addresses off the + * `ConnectResult` / `LoginStatus` / `SessionHandle` already-resolved + * triples, never by calling this directly. + * + * @internal + */ +export function deriveSessionAddresses(session: UserSession): SessionAddresses { + const rootBytes = new Uint8Array(session.rootAccountId); + const productPubkey = derivePlaygroundProductPublicKey(rootBytes, { + productId: PLAYGROUND_PRODUCT_ID, + derivationIndex: 0, + }); + return { + rootAddress: ss58Encode(rootBytes), + productAddress: ss58Encode(productPubkey), + productH160: deriveH160(productPubkey), + }; } export type ConnectResult = - | { kind: "existing"; address: string } + | { kind: "existing"; address: string; addresses: SessionAddresses } | { kind: "qr"; qrCode: string; login: LoginHandle }; export type LoginStatus = @@ -81,7 +133,7 @@ export type LoginStatus = * surface to the user verbatim. */ | { step: "pending"; stage: string } - | { step: "success"; address: string } + | { step: "success"; address: string; addresses: SessionAddresses } | { step: "error"; message: string }; export interface LoginHandle { @@ -101,7 +153,11 @@ export async function connect(): Promise { const sessions = await waitForSessions(adapter); if (sessions.length > 0) { - return { kind: "existing", address: sessionSigningAddress(sessions[0]) }; + const addresses = deriveSessionAddresses(sessions[0]); + // `address` is kept for back-compat with callers that only need the + // product-account SS58 (signer flows). UI consumers should read the + // richer `addresses` field instead. + return { kind: "existing", address: addresses.productAddress, addresses }; } // Start authenticate — this triggers the pairing flow and QR emission @@ -193,8 +249,9 @@ export async function waitForLogin( if (authenticated) { const sessions = await waitForSessions(adapter, 3000); if (sessions.length > 0) { - address = sessionSigningAddress(sessions[0]); - onStatus({ step: "success", address }); + const addresses = deriveSessionAddresses(sessions[0]); + address = addresses.productAddress; + onStatus({ step: "success", address, addresses }); } else { onStatus({ step: "error", @@ -224,7 +281,14 @@ export async function waitForLogin( * in the right order. */ export interface SessionHandle { + /** + * Product-account SS58. Kept as a top-level field for back-compat with + * `signer.ts::resolveSigner` and its downstream consumers + * (`ResolvedSigner.address` ends up here). Equal to + * `addresses.productAddress`. UI code should prefer `addresses`. + */ address: string; + addresses: SessionAddresses; signer: PolkadotSigner; userSession: UserSession; destroy(): void; @@ -262,7 +326,7 @@ export async function getSessionSigner(): Promise { const session = sessions[0]; const signer = createPlaygroundSigner(session); - const address = ss58Encode(signer.publicKey); + const addresses = deriveSessionAddresses(session); let destroyed = false; const destroy = () => { @@ -278,7 +342,13 @@ export async function getSessionSigner(): Promise { adapter.destroy().catch(() => {}); }; - return { address, signer, userSession: session, destroy }; + return { + address: addresses.productAddress, + addresses, + signer, + userSession: session, + destroy, + }; } // ── Sign-out flow ───────────────────────────────────────────────────────────── @@ -324,7 +394,7 @@ export async function findSession(): Promise { return null; } const session = sessions[0]; - const address = sessionSigningAddress(session); + const address = deriveSessionAddresses(session).productAddress; return { adapter, address, session }; } diff --git a/src/utils/sessionSigner.ts b/src/utils/sessionSigner.ts index 6b1535e..7abdd61 100644 --- a/src/utils/sessionSigner.ts +++ b/src/utils/sessionSigner.ts @@ -64,6 +64,29 @@ export interface ProductAccountRef { derivationIndex: number; } +/** + * Soft-derive the product account public key off a wallet root. + * + * This is the single source of truth for product-account math in the CLI. + * Both `createPlaygroundSessionSigner` (which builds the signer used to + * actually sign on-chain) and `auth.ts::deriveSessionAddresses` (which + * builds the display triple for `dot init`) go through here so a future + * change to derivation params can't silently desync the signer from + * what we print. + * + * sr25519 soft derivation is composable on public keys alone, so deriving + * from `rootAccountId` locally produces the SAME public key the mobile + * derives privately via `mnemonic + "/product/...{idx}"`. Algorithm + * parity with mobile/desktop is locked by the frozen vectors in + * `@parity/product-sdk-keys`'s `product-account.test.ts`. + */ +export function derivePlaygroundProductPublicKey( + rootAccountId: Uint8Array, + ref: ProductAccountRef, +): Uint8Array { + return deriveProductAccountPublicKey(rootAccountId, ref.productId, ref.derivationIndex); +} + /** * Identifiers whose payload PAPI may populate but the PJS adapter doesn't * recognize. Mirrors `RELAXED_SIGNED_EXTENSIONS` in the polkadot-app sample. @@ -107,17 +130,10 @@ export function createPlaygroundSessionSigner( // (the product account derived at `/product/{productId}/{idx}`). // // `session.rootAccountId` is the handshake-time `rootUserAccountId` — - // the user's bare-mnemonic keypair public key (`deriveRootAccount()` = - // `derivationPath = null`). Sr25519 soft derivation is composable on - // public keys alone, so deriving from it locally produces the SAME public - // key the mobile derives privately via `mnemonic + "/product/...{idx}"`. - // Algorithm parity with mobile/desktop is locked by the frozen vectors in - // `@parity/product-sdk-keys`'s `product-account.test.ts`. - const publicKey = deriveProductAccountPublicKey( - new Uint8Array(session.rootAccountId), - ref.productId, - ref.derivationIndex, - ); + // the user's bare-mnemonic keypair public key on current mobile builds + // (`deriveRootAccount()` = `derivationPath = null`). See the "Accounts" + // section in CLAUDE.md for the host-vs-mobile derivation map. + const publicKey = derivePlaygroundProductPublicKey(new Uint8Array(session.rootAccountId), ref); const address = ss58Encode(publicKey); // Wire-shape identifier passed to host-papp's `signPayload` / `signRaw`.