Skip to content

fix(dot init): stop double-deriving the product account; key username on wallet root#188

Merged
UtkarshBhardwaj007 merged 1 commit into
mainfrom
fix/dot-init-account-derivation
May 19, 2026
Merged

fix(dot init): stop double-deriving the product account; key username on wallet root#188
UtkarshBhardwaj007 merged 1 commit into
mainfrom
fix/dot-init-account-derivation

Conversation

@UtkarshBhardwaj007

Copy link
Copy Markdown
Member

Summary

Follow-up to #185. Three coupled bugs in dot init's identity block, surfaced when comparing the CLI's output to the playground-app's [playground.dot] selected account log and to the mobile app's developer-tools debug screen.

1. Stop double-deriving the product account

IdentityLines called productAccountDisplay(loggedInAddress). loggedInAddress was already the product account (the output of createPlaygroundSessionSigner.publicKey). The helper ss58-decoded it and ran deriveProductAccountPublicKey a second time, producing a doubly-derived ghost address whose H160 didn't match what the playground-app uses.

Fix: a SessionAddresses = { rootAddress, productAddress, productH160 } triple is computed once in auth.ts::deriveSessionAddresses via a new shared helper derivePlaygroundProductPublicKey in sessionSigner.ts. Both the signer constructor and the display now go through the same function, so they can't drift.

Verified end-to-end against the playground-app's log for the test mnemonic train snow there sponsor artwork zebra gossip depth narrow blame change private:

Playground-app log CLI (after fix)
product address 5GGpUaN7XNaUp3nEVDPBSR4SQLxFxQsiPHbFwf69Apr3HgDZ matches
product derivedH160 0x47f68a0851a663dfacb4610d673ec708f05576b0 matches

Frozen-vector regression test in src/utils/auth.test.ts::deriveSessionAddresses locks the contract — three tests: known-vector match, product != root, and H160 moves in lock-step with the product SS58 when the root changes.

2. Username lookup keyed on the wallet root

lookupUsername was being called with the product account. Usernames live at Resources.Consumers[<rootAccountId>] on the People parachain — mobile's Resources.register_person writes them keyed on the SSO rootAccountId. Polkadot-desktop's useSessionIdentity(session) reads the same root key. IdentityLines now calls lookupUsername(addresses.rootAddress).

3. "logged in" label

The row used to show the product account, which the user reasonably expects to be their wallet identity. It now shows addresses.rootAddress (the SSO-handshake rootUserAccountId). The product account is on its own row underneath with the full SS58 + H160.

This still won't match what the mobile app shows as "Wallet account address" on its debug screen (hard //wallet junction; the host can't reach it from a public key alone) — but it does match what the SSO handshake actually transmits and is the storage key the username lookup uses.

What the user sees now

✓ logged in       5FZEMcMGTjSveipHTD35RsRtMqZf2wk41g2zAPL8j2UwWTrp
✓ username        alice.dot    (or "(no username set on chain)")
✓ product account 5GGpUaN7XNaUp3nEVDPBSR4SQLxFxQsiPHbFwf69Apr3HgDZ (0x47f68a0851a663dfacb4610d673ec708f05576b0)

Documentation

CLAUDE.md gets a new "Accounts: root, product, and what the mobile app shows" section under "Non-obvious invariants" so the next maintainer can re-verify the host-vs-mobile derivation map without re-reading host-papp's handshake codec.

Mechanical changes

  • src/utils/sessionSigner.ts: export derivePlaygroundProductPublicKey(rootAccountId, ref) — single source of truth for product-account math.
  • src/utils/auth.ts: new SessionAddresses type + deriveSessionAddresses(session) helper. ConnectResult.existing, LoginStatus.success, and SessionHandle carry the bundle. SessionHandle.address kept as a back-compat alias.
  • src/commands/init/IdentityLines.tsx: take SessionAddresses, render three rows, look up username against root.
  • src/commands/init/InitScreen.tsx: track addresses instead of loggedInAddress. The "logged in" row now lives inside IdentityLines only.
  • src/commands/init/QrLogin.tsx: pass full SessionAddresses through onDone; suppress the duplicate "logged in" row on success.
  • src/commands/init/index.ts: rename existingAddressexistingAddresses.
  • src/commands/init/identityLine.ts + identityLine.test.ts: deleted. The helpers encoded the exact wrong shape and have no remaining callers.

Test plan

  • pnpm format:check (145 files clean)
  • pnpm lint:license (172/172 files)
  • pnpm test — 536 passed, 1 skipped (added 3 new regression tests in the deriveSessionAddresses block)
  • Live derivation match against the playground-app's published log for the test mnemonic
  • Manual dot init post-merge: confirm three rows render with full root + product SS58 + H160; confirm username lookup completes against the root (returns (no username set on chain) for accounts that haven't claimed one, otherwise the actual username)

… on wallet root

Three coupled bugs in `dot init`'s identity block.

1. Double-derivation. `IdentityLines` called `productAccountDisplay`
   on `loggedInAddress`, which was already the product account (the
   output of `createPlaygroundSessionSigner.publicKey`). The helper
   then ran `deriveProductAccountPublicKey` again on the SS58-decoded
   pubkey, producing a doubly-derived ghost (`5DHk4grp...`). The H160
   we printed didn't match the playground-app's `derivedH160` for
   the same account.

   Fix: a `SessionAddresses = { rootAddress, productAddress,
   productH160 }` triple is computed once in `auth.ts` via the new
   `derivePlaygroundProductPublicKey` helper in `sessionSigner.ts`,
   then threaded through `ConnectResult`, `LoginStatus.success`, and
   `SessionHandle`. The signer and the display now share one
   derivation function, so a future change to product-account params
   can't desync the two.

   Verified end-to-end against the playground-app's published log
   for the test mnemonic `train snow there sponsor artwork zebra
   gossip depth narrow blame change private`:

     address      = 5GGpUaN7XNaUp3nEVDPBSR4SQLxFxQsiPHbFwf69Apr3HgDZ
     derivedH160  = 0x47f68a0851a663dfacb4610d673ec708f05576b0

   These now match what `dot init` prints. A frozen-vector
   regression test in `src/utils/auth.test.ts::deriveSessionAddresses`
   locks the contract.

2. Username key. `lookupUsername` was being called with the product
   account; usernames live at `Resources.Consumers[<rootAccountId>]`
   on the People parachain. Storage is written by mobile's
   `Resources.register_person` keyed on the SSO `rootAccountId`, not
   any product derivation. Polkadot-desktop's `useSessionIdentity`
   reads the same root key. `IdentityLines` now calls
   `lookupUsername(addresses.rootAddress)`.

3. "logged in" label. The row labelled "logged in" used to show the
   product account, which a user reasonably expects to be their
   wallet identity. It now shows `addresses.rootAddress` (the
   SSO-handshake `rootUserAccountId`). The product account is on its
   own row underneath with the full SS58 + H160.

   This still won't match what the mobile app shows as "Wallet
   account address" on its debug screen (hard `//wallet` junction
   the host can't reproduce from a public key) — but it does match
   what the SSO handshake actually transmits and is the key the
   username lookup is keyed on.

CLAUDE.md gets a new "Accounts: root, product, and what the mobile
app shows" section under "Non-obvious invariants" so the next
maintainer doesn't have to re-derive this from host-papp's codec.

Mechanically:

- `src/utils/sessionSigner.ts`: export
  `derivePlaygroundProductPublicKey(rootAccountId, ref)`. Single
  source of truth for product-account math; called by the signer
  constructor and by `auth.ts::deriveSessionAddresses`.
- `src/utils/auth.ts`: new `SessionAddresses` type and
  `deriveSessionAddresses(session)` helper. `ConnectResult.existing`,
  `LoginStatus.success`, and `SessionHandle` carry the 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 /
  registry code reads `.address` for the signing key.
- `src/commands/init/IdentityLines.tsx`: take `SessionAddresses`,
  render three rows, look up username against the root.
- `src/commands/init/InitScreen.tsx`: track `addresses` instead of
  `loggedInAddress`. The "logged in" row is now rendered inside
  `IdentityLines` only.
- `src/commands/init/QrLogin.tsx`: pass the full `SessionAddresses`
  through `onDone`; suppress the duplicate "logged in" row on
  success.
- `src/commands/init/index.ts`: rename `existingAddress` to
  `existingAddresses`.
- `src/commands/init/identityLine.ts` + `identityLine.test.ts`:
  deleted. The helpers encoded the exact wrong shape and have no
  remaining callers.
- `src/utils/auth.test.ts`: three regression tests for
  `deriveSessionAddresses` — frozen vector match, product != root,
  H160-tracks-SS58.
@UtkarshBhardwaj007 UtkarshBhardwaj007 merged commit 2211853 into main May 19, 2026
17 of 18 checks passed
@UtkarshBhardwaj007 UtkarshBhardwaj007 deleted the fix/dot-init-account-derivation branch May 19, 2026 19:22
@github-actions

Copy link
Copy Markdown
Contributor

E2E Test Pass · ✅ PASS

Tag: e2e-ci-pr · Branch: fix/dot-init-account-derivation · Commit: 7cd286f · Run logs

Cell Result Time
pr-deploy-cdm ✅ PASS 2m34s
pr-mod ✅ PASS 2m00s
pr-preflight ✅ PASS 2m54s
pr-install ✅ PASS 0m53s
pr-init-session ✅ PASS 1m51s
pr-deploy-frontend ✅ PASS 3m13s
pr-deploy-foundry ✅ PASS 1m09s
${{ matrix.cell }} ⏭️ SKIP 0m-1s
${{ matrix.cell }} ⏭️ SKIP 0m-1s

Sentry traces: view spans for this run

shawntabrizi pushed a commit to shawntabrizi/playground-cli that referenced this pull request May 27, 2026
The mobile app (Polkadot Android NIGHTLY) submits the RFC-0010
`Resources::claim_long_term_storage` extrinsic on the People parachain
and then waits 30 s for the authorization to propagate to Bulletin
Chain. That wait is .logFailure()-wrapped — failures are swallowed and
the slot key is returned regardless. The CLI then polls Bulletin via
`checkAuthorization` from @parity/product-sdk-bulletin for 75 s; if
propagation lags, the poll throws "Mobile returned Bulletin allowance
key X, but it is not authorized on Bulletin yet" and `dot init`
aborts before even running funding/mapping.

Verified end-to-end against the polkadot-app-android-v2 source
(`RealTransactionStorageSlotAllocator.kt:53-114`, AWAIT_BULLETIN_TIMEOUT
= 30s, `.logFailure(...)` at the `awaitAllocationVisibleOnBulletIn`
call site) and RFC-0010's "Bulletin submission — implicit allocation"
flow. The host has no on-chain step to add here — RFC-0010 puts all
the chain-side work on the mobile. The CLI's only correct move is to
be resilient to the propagation lag.

Four fixes in this PR, plus a React warning from paritytech#188:

1. `BULLETIN_AUTHORIZATION_URL` + `bulletinAuthorizationHelp(slot)`
   in `src/config.ts` and `src/utils/allowances/bulletin.ts`. Both
   `waitForBulletinSlotAuthorization`'s timeout error and the
   cached-key-not-authorized error now print the slot SS58 + the
   manual-faucet URL
   (`https://paritytech.github.io/polkadot-bulletin-chain/authorizations`).
   Pattern lifted from PR paritytech#186.

2. `requestAndStoreBulletinAllowanceSigner` now persists the slot
   key + marker BEFORE the propagation wait. A 75 s timeout no
   longer throws away a perfectly valid key the mobile derived
   from `mnemonic + //allowance//bulletin//<productId>` — the
   key will still be valid the moment the chain catches up.

3. `AccountSetup` makes a Bulletin propagation timeout a SOFT
   failure rather than aborting `dot init`. The row shows the
   help string + slot SS58; funding + Revive auto-map mapping
   still run because they depend on the product account on
   Asset Hub, not on Bulletin authorization. The final
   `setup complete some account setup steps failed` warning
   surfaces correctly.

4. `storeSlotAccountKeysFromOutcomes` is now a single
   read-modify-write so two slot keys returned in one
   `requestResourceAllocation` call (e.g. `BulletInAllowance` +
   `StatementStoreAllowance`) can't race-clobber each other.
   The previous `Promise.all(...storeSlotAccountKey)` had each
   call load the JSON, mutate one resource, save the JSON;
   interleaved writes would drop the second sibling's key.

Also fixes a React "Cannot update a component while rendering a
different component" warning introduced in paritytech#188 (the account-
derivation PR). `QrLogin` was calling `onDone(...)` from inside a
`setStatus(updater)` callback — which runs during React's render
phase, so the parent's `setAddresses` / `setAuthResolved` triggered
mid-render of QrLogin. Now snapshots the resolved `SessionAddresses`
in a `useRef` inside the `onStatus` callback and calls `onDone` after
the promise resolves, outside any updater.

Why this slipped past CI: `vitest.config.ts` only picks up
`*.test.ts`, not `*.tsx`, so React-strict-mode warnings never fire
in the test suite.

Tests: 538 pass + 1 skipped (was 536). Two new regression tests:
the help helper formats SS58 + URL correctly, and
`storeSlotAccountKeysFromOutcomes` preserves sibling keys.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant