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

Fix `dot init` identity block: print the full product-account SS58 and 0x-prefixed H160 instead of truncated `5DHk4g...CzE1 (0x8849...29dc)`, and fix the username lookup so it actually queries `Resources.Consumers` correctly. The previous code routed the SS58 through `AccountId().dec(...)` (which is meant for `0x`-hex input, not SS58) and silently corrupted the storage key, so every lookup surfaced as `(lookup failed)`. Now the SS58 is passed straight to `getValues`, matching the polkadot-desktop / dotli / triangle-js-sdks pattern.
516 changes: 181 additions & 335 deletions CLAUDE.md

Large diffs are not rendered by default.

13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Flags:
- `--private` — publish to the playground with private (owner-only) visibility. Requires `--playground`. Not interactively prompted; pass the flag to opt in.
- `--moddable` / `--no-moddable` — publish the source repo URL alongside the deploy so others can `dot mod` it. Requires `--playground`. Interactive prompt (default: no) if omitted. The CLI reads your existing `origin` and records its URL in the Bulletin metadata; it never creates a repo or pushes for you. The deploy fails with an actionable message if `origin` is unset, points to a private repo, or points to anything other than GitHub (since `dot mod` only fetches from `codeload.github.com`). Set up the repo yourself before re-running: create a public repo on GitHub, then `git remote add origin https://github.com/<user>/<repo>` followed by `git push -u origin main`. (If you happen to have `gh` installed, `gh repo create my-app --public --source=. --push` does both in one shot — `dot` does not require `gh`.)
- `--suri <suri>` — override signer with a dev secret URI (e.g. `//Alice`). Useful for CI.
- `--env <env>` — `testnet` (default) or `mainnet` (not yet supported).
- `--env <env>` — target environment. Defaults to `paseo-next-v2` (the only one fully wired today). Accepts the bulletin-deploy env IDs (`preview`, `paseo-next`, `paseo-review`, `paseo-next-v2`, `polkadot`, `kusama`) plus the legacy `testnet`/`mainnet` aliases — `testnet` maps to `paseo-next-v2`, `mainnet` to `polkadot`. Any env other than `paseo-next-v2` throws "not supported" until its entry is wired up in `src/config.ts::CONFIGS`.

Passing all four of `--signer`, `--domain`, `--buildDir`, and `--playground` runs in fully non-interactive mode. Any absent flag is filled in by the TUI prompt. `--moddable`, `--private`, and `--contracts` are independently optional in both modes — their absence means a non-moddable, public, frontend-only deploy.

Expand Down Expand Up @@ -216,15 +216,16 @@ The first two are also enforced in CI; running them locally catches the failure
## Dependency Notes

- `@parity/product-sdk-*` packages use caret ranges (`^0.x.y`) so upstream patch and minor releases auto-resolve on a fresh `pnpm install`. With pre-1.0 versions, `^` only widens patches within the current 0.x line — a 0.x → 0.(x+1) bump still requires an intentional `package.json` change. CI's `Format` job runs a grep guard that fails the build on any direct `@polkadot-apps/*` import in `src/`, `e2e/`, `scripts/`, or `tools/`.
- `@dotdm/contracts` is pinned to the explicit dev tag `1.1.1-dev.1778274929` — that's the npm publish from the CDM monorepo's `migrate-to-product-sdk` PR (paritytech/contract-dependency-manager#13), the first build that consumes `@parity/product-sdk-*` instead of the legacy `@polkadot-apps/*` line. The `latest` stable (`1.1.1`) still pulls `@polkadot-apps/*` + `polkadot-api@1.x`. Move back to a caret or `latest` once that migrated build is promoted.
- `@novasamatech/*` packages are forced to `0.7.8-2` via `pnpm.overrides`. They come in transitively from `@parity/product-sdk-*` whose `^0.7.7` caret doesn't auto-widen across patches in lockfile updates; the override aligns the tree on the latest Novasama publish. Drop the override once product-sdk widens its own caret.
- `polkadot-api` is pinned to `^2.1.2` and `@polkadot-api/sdk-ink` to `^0.7.0`. The lockfile contains a stale `polkadot-api@1.23.3` only because `@parity/dotns-cli`'s declared dep references it; that CLI ships as a single bundled `dist/cli.js` with all deps inlined, so the 1.x decl is never resolved at runtime. Effectively the runtime is PAPI 2.x-only.
- `bulletin-deploy` is pinned to an explicit version — not `latest`. Currently `0.7.13`. Previously `latest` pointed at 0.6.8 which had a WebSocket heartbeat bug (40s default < 60s chunk timeout) that tore chunk uploads down as `WS halt (3)`; keeping the pin explicit avoids ever sliding back onto that. When bumping, check the release notes for any changes to `deploy()` / `DotNS` APIs we rely on.
- `@dotdm/contracts` is on the `^2.0.x` caret. The 2.0 line is the first to consume `@parity/product-sdk-*` directly; the legacy `1.1.1` stable still pulls `@polkadot-apps/*` + `polkadot-api@1.x` and must NOT be downgraded to. Patch bumps within 2.x are safe.
- `@novasamatech/*` packages are forced to `0.7.9-4` via `pnpm.overrides`. They come in transitively from `@parity/product-sdk-terminal` whose `^0.7.7` caret doesn't auto-widen across patches in lockfile updates; the override aligns the tree on the latest published Novasama line (including RFC-0010 `requestResourceAllocation` on `UserSession`). Drop the override once product-sdk-terminal widens its own caret.
- `polkadot-api` is on `^2.1.x` and `@polkadot-api/sdk-ink` on `^0.7.0`. The lockfile contains a stale `polkadot-api@1.x` only because `@parity/dotns-cli`'s declared dep references it; that CLI ships as a single bundled `dist/cli.js` with all deps inlined, so the 1.x decl is never resolved at runtime. Effectively the runtime is PAPI 2.x-only.
- `bulletin-deploy` is pinned to an explicit version — not `latest`. Currently `0.7.24`. Previously `latest` pointed at 0.6.8 which had a WebSocket heartbeat bug (40s default < 60s chunk timeout) that tore chunk uploads down as `WS halt (3)`; keeping the pin explicit avoids ever sliding back onto that. When bumping, check the release notes for any changes to `deploy()` / `DotNS` APIs we rely on (`jsMerkle`, `signer`, `signerAddress`, `mnemonic`, `rpc`, `attributes`).
- `pnpm.overrides` also redirects `@parity/dotns-cli`'s declared `@polkadot-api/descriptors` dep to `stubs/papi-descriptors-stub/`. `@parity/dotns-cli@0.6.1`'s published manifest references a workspace path (`file:.papi/descriptors`) that doesn't exist in the tarball; pnpm refuses, npm tolerates it. dotns-cli ships as a fully-bundled `dist/cli.js` so the stub (exporting `{}`) is functionally correct. Remove once `@parity/dotns-cli` republishes a clean manifest.

## Architecture Highlights

- **Single config module** (`src/config.ts`) — all chain URLs, contract addresses, dapp identifiers and the `testnet`/`mainnet` switch live here. Nothing else in the tree should hard-code an endpoint or address.
- **QR-paired session signer** (`src/utils/auth.ts::createPlaygroundSigner`) — wraps `@parity/product-sdk-terminal@0.2.0+`'s `createSessionSignerForAccount`, which builds a PJS signer with split callbacks under the hood: tx → `session.signPayload` (chain-tx context, no envelope), bytes → `session.signRaw` (mobile applies the `<Bytes>…</Bytes>` envelope, correct for arbitrary data). The product account derives from `productId="playground.dot"` + `derivationIndex=0`, which matches what the playground-app uses on the host side so the same address signs in both surfaces.
- **QR-paired session signer** (`src/utils/auth.ts::createPlaygroundSigner`) — wraps `@parity/product-sdk-terminal@0.2.1+`'s `createSessionSignerForAccount`, a PAPI-native signer that routes extrinsic payloads through `session.signRaw({ data: { tag: "Payload", value: hex(toSign) } })` (opaque bytes, no `<Bytes>` envelope) so every signed extension declared by the chain — including paseo-next-v2's `AsPgas` — survives end-to-end. The product account derives from `productId="playground.dot"` + `derivationIndex=0`, which matches what the playground-app uses on the host side so the same address signs in both surfaces.
- **Unified signer resolution** (`src/utils/signer.ts`) — one `resolveSigner({ suri? })` call returns a `ResolvedSigner` whether the user is authenticated via QR session or a dev `//Alice`-style URI. Every command threads the result through to its operations instead of branching on source.
- **Connection singleton** (`src/utils/connection.ts`) — stores the promise (not the resolved client) so concurrent callers share a single WebSocket. Has a 30s timeout and preserves the underlying error via `Error.cause` for debugging.
- **Session lifecycle** (`src/utils/auth.ts`) — `getSessionSigner()` returns an explicit `destroy()` handle. Callers MUST call it (typically from a `useEffect` cleanup) — the host-papp adapter keeps the Node event loop alive.
Expand Down
14 changes: 8 additions & 6 deletions src/commands/init/IdentityLines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ import { productAccountDisplay } from "./identityLine.js";
* Two-line identity block shown after a successful login:
*
* username alice.dot
* product account 5Grwva...utQY (0x1a2b...ef34)
* product account <full ss58> (<full 0x h160>)
*
* 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`.
* 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`.
*/
export function IdentityLines({ address }: { address: string }) {
const [username, setUsername] = useState<UsernameLookup>({ kind: "loading" });
Expand Down
14 changes: 11 additions & 3 deletions src/commands/init/identityLine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ 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);
// 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}$/);
});

Expand All @@ -40,8 +44,12 @@ describe("productAccountAddresses", () => {
});

describe("productAccountDisplay", () => {
it("renders 'ss58 (h160)' with both addresses truncated", () => {
it("renders 'ss58 (h160)' with the full SS58 + full 0x-prefixed H160", () => {
const display = productAccountDisplay(ZERO_ROOT_SS58);
expect(display).toMatch(/^.+\.\.\..+ \(0x.+\.\.\..+\)$/);
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("...");
});
});
4 changes: 2 additions & 2 deletions src/commands/init/identityLine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
* `completion.ts` next to `InitScreen.tsx` for the same pattern).
*/

import { deriveH160, ss58Decode, ss58Encode, truncateAddress } from "@parity/product-sdk-address";
import { deriveH160, ss58Decode, ss58Encode } from "@parity/product-sdk-address";
import { deriveProductAccountPublicKey } from "@parity/product-sdk-keys";
import { PLAYGROUND_PRODUCT_ID } from "../../config.js";

Expand All @@ -40,5 +40,5 @@ export function productAccountAddresses(rootAccountSs58: string): ProductAccount

export function productAccountDisplay(rootAccountSs58: string): string {
const { ss58, h160 } = productAccountAddresses(rootAccountSs58);
return `${truncateAddress(ss58, 6, 4)} (${truncateAddress(h160, 6, 4)})`;
return `${ss58} (${h160})`;
}
97 changes: 96 additions & 1 deletion src/utils/username.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { formatUsernameLine, type UsernameLookup } from "./username.js";

describe("formatUsernameLine", () => {
Expand Down Expand Up @@ -53,3 +53,98 @@ describe("formatUsernameLine", () => {
expect(formatUsernameLine(lookup)).toBe("(looking up...)");
});
});

// Mocks must be set up at module-load time so the polkadot-api imports inside
// `username.ts` resolve to our stubs. The pattern mirrors `connection.test.ts`.
const mockGetValues = vi.fn();
const mockCreateClient = vi.fn();
const mockGetWsProvider = vi.fn();
const mockDestroy = vi.fn();

vi.mock("polkadot-api", () => ({
createClient: (provider: unknown) => mockCreateClient(provider),
}));

vi.mock("polkadot-api/ws", () => ({
getWsProvider: (endpoints: unknown) => mockGetWsProvider(endpoints),
}));

describe("lookupUsername", () => {
beforeEach(() => {
vi.resetModules();
mockGetValues.mockReset();
mockCreateClient.mockReset();
mockGetWsProvider.mockReset();
mockDestroy.mockReset();

mockGetWsProvider.mockImplementation(() => ({}));
mockCreateClient.mockImplementation(() => ({
destroy: mockDestroy,
getUnsafeApi: () => ({
query: {
Resources: {
Consumers: {
getValues: mockGetValues,
},
},
},
}),
}));
});

// Regression guard: under scale-ts's `fromHex`-based string decoder,
// routing the SS58 through `AccountId().dec(...)` silently corrupts it
// (most SS58 chars aren't in `HEX_MAP`) and the storage call surfaces as
// `(lookup failed)`. The whole bug class disappears as long as we pass
// the SS58 string through unchanged — this test fails if anyone
// reintroduces a codec round-trip.
it("passes the SS58 string directly to getValues, with no codec round-trip", async () => {
const ss58 = "5GGpUaN7XNaUp3nEVDPBSR4SQLxFxQsiPHbFwf69Apr3HgDZ";
mockGetValues.mockResolvedValue([null]);

const { lookupUsername } = await import("./username.js");
const result = await lookupUsername(ss58);

expect(mockGetValues).toHaveBeenCalledTimes(1);
expect(mockGetValues).toHaveBeenCalledWith([[ss58]]);
expect(result).toEqual({ kind: "none" });
});

it("returns 'found' with decoded usernames when the chain has a record", async () => {
const fullUsername = new TextEncoder().encode("alice.dot");
const liteUsername = new TextEncoder().encode("alice");
mockGetValues.mockResolvedValue([
{ full_username: fullUsername, lite_username: liteUsername, credibility: null },
]);

const { lookupUsername } = await import("./username.js");
const result = await lookupUsername("5GGpUaN7XNaUp3nEVDPBSR4SQLxFxQsiPHbFwf69Apr3HgDZ");

expect(result).toEqual({
kind: "found",
fullUsername: "alice.dot",
liteUsername: "alice",
});
});

it("returns 'error' if the Resources.Consumers storage entry is missing from chain metadata", async () => {
mockCreateClient.mockImplementation(() => ({
destroy: mockDestroy,
getUnsafeApi: () => ({
query: { Resources: undefined },
}),
}));

const { lookupUsername } = await import("./username.js");
const result = await lookupUsername("5GGpUaN7XNaUp3nEVDPBSR4SQLxFxQsiPHbFwf69Apr3HgDZ");

expect(result.kind).toBe("error");
});

it("destroys the per-call client to release the WebSocket", async () => {
mockGetValues.mockResolvedValue([null]);
const { lookupUsername } = await import("./username.js");
await lookupUsername("5GGpUaN7XNaUp3nEVDPBSR4SQLxFxQsiPHbFwf69Apr3HgDZ");
expect(mockDestroy).toHaveBeenCalledTimes(1);
});
});
32 changes: 24 additions & 8 deletions src/utils/username.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,32 @@
* 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.
* NOTE on the storage key: `unsafeApi.query.Resources.Consumers.getValues`
* expects the key in JS form — for `AccountId32`, that's an SS58 string. The
* upstream `createIdentityRpcAdapter` runs the input through
* `AccountId().dec(x)` because *its* caller passes a 0x-prefixed pubkey hex
* (see dotli `packages/auth/src/auth.ts`, which calls `getIdentity(\`0x${pk}\`)`),
* so the `.dec` round-trips hex → SS58 before handing it to PAPI. We already
* receive the SS58 string from the QR-login flow, so the `.dec` step would
* silently corrupt it: under the hood `.dec` runs the string through
* scale-ts's `fromHex`, which reads each character via `HEX_MAP[ch]` — most
* SS58 chars (`G`, `H`, `J`, `K`, `P`, `U`, `p`, `r`, …) aren't in the map
* so they coerce to 0 (`undefined << 4 | undefined` → `0`). The resulting
* mostly-zero buffer is then re-encoded by `fromBufferToBase58` into a
* malformed SS58 (wrong length, wrong checksum). That bogus key is what
* gets handed to PAPI's storage encoder, where `getSs58AddressInfo` rejects
* it and the lookup surfaces as `(lookup failed)`. Pass the SS58 directly.
*/

import { AccountId, createClient } from "polkadot-api";
import { createClient } from "polkadot-api";
import { getWsProvider } from "polkadot-api/ws";
import { getChainConfig } from "../config.js";

const LOOKUP_TIMEOUT_MS = 5_000;
// 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.
// The success path is sub-second on a fast network; the 10s budget only kicks
// in when the chain is genuinely unreachable.
const LOOKUP_TIMEOUT_MS = 10_000;

export type UsernameLookup =
| { kind: "loading" }
Expand Down Expand Up @@ -79,8 +95,6 @@ export async function lookupUsername(rootAccountSs58: string): Promise<UsernameL
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) {
Expand All @@ -91,7 +105,9 @@ export async function lookupUsername(rootAccountSs58: string): Promise<UsernameL
}

const result = await Promise.race([
query.getValues([[accountKey]]) as Promise<Array<ConsumerRecord | undefined | null>>,
query.getValues([[rootAccountSs58]]) as Promise<
Array<ConsumerRecord | undefined | null>
>,
new Promise<"timeout">((resolve) =>
setTimeout(() => resolve("timeout"), LOOKUP_TIMEOUT_MS),
),
Expand Down
Loading