From 9f1e4dc5d931c45a1884ab0644d1d7f17d02a690 Mon Sep 17 00:00:00 2001 From: UtkarshBhardwaj007 Date: Tue, 19 May 2026 21:12:48 +0100 Subject: [PATCH 1/2] fix(dot init): bulletin allowance resilience + QrLogin setState warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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 #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//` — 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 #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. --- .changeset/bulletin-allowance-resilience.md | 11 +++ src/commands/init/AccountSetup.tsx | 75 +++++++++++++++------ src/commands/init/QrLogin.tsx | 26 ++++--- src/config.ts | 10 +++ src/utils/allowances/bulletin.test.ts | 14 +++- src/utils/allowances/bulletin.ts | 38 +++++++++-- src/utils/allowances/slotKeys.test.ts | 26 +++++++ src/utils/allowances/slotKeys.ts | 42 ++++++++---- 8 files changed, 190 insertions(+), 52 deletions(-) create mode 100644 .changeset/bulletin-allowance-resilience.md diff --git a/.changeset/bulletin-allowance-resilience.md b/.changeset/bulletin-allowance-resilience.md new file mode 100644 index 0000000..f834011 --- /dev/null +++ b/.changeset/bulletin-allowance-resilience.md @@ -0,0 +1,11 @@ +--- +"playground-cli": patch +--- + +Make `dot init` survive Bulletin allowance propagation lag, and fix a React setState warning that landed in the previous account-derivation PR. + +- **`dot init` no longer aborts** when the RFC-0010 Bulletin slot account is returned by mobile but the on-chain authorization hasn't propagated to Bulletin Chain yet. The slot key + marker are persisted regardless (so the next `dot deploy` picks them up), and the funding/mapping step continues to run. The row shows a soft-failure warning with the slot account SS58 and a faucet URL. +- New `BULLETIN_AUTHORIZATION_URL` + `bulletinAuthorizationHelp(slotAddress)` so timeout / cached-key-not-authorized errors point at `https://paritytech.github.io/polkadot-bulletin-chain/authorizations` with the exact slot SS58 to authorize manually. +- `requestAndStoreBulletinAllowanceSigner` persists the slot key before waiting for chain confirmation. A propagation timeout no longer discards a valid key the mobile already derived. +- `storeSlotAccountKeysFromOutcomes` is now a single read-modify-write so two slot keys returned in one call (e.g. BulletInAllowance + StatementStoreAllowance) can't race-clobber each other in `allowance-keys.json`. +- Fix a "Cannot update a component while rendering a different component" warning from `QrLogin`: it was calling the parent's `onDone(setState)` from inside `setStatus(updater)`. The handler now captures the resolved addresses in a `useRef` and calls `onDone` after the promise resolves, outside any updater function. diff --git a/src/commands/init/AccountSetup.tsx b/src/commands/init/AccountSetup.tsx index 68387e7..925c432 100644 --- a/src/commands/init/AccountSetup.tsx +++ b/src/commands/init/AccountSetup.tsx @@ -29,11 +29,13 @@ import { } from "../../utils/allowances/host.js"; import { hasAllowance, markAllowance } from "../../utils/allowances/marker.js"; import { + bulletinAuthorizationHelp, hasUsableBulletinSlotAuthorization, waitForBulletinSlotAuthorization, } from "../../utils/allowances/bulletin.js"; import { extractSlotAccountKey, + getSlotAccountAddress, hasSlotAccountKey, readSlotAccountKey, storeSlotAccountKeysFromOutcomes, @@ -148,8 +150,21 @@ export function AccountSetup({ // Allowances are requested against the product-derived account // (host-papp's `productAccountId = [PLAYGROUND_PRODUCT_ID, 0]`), // which is the same SS58 used everywhere else in this flow. + // + // A note on non-fatal Bulletin timeouts: mobile derives the slot + // account from the user's root and submits `claim_long_term_storage` + // on People Chain; the authorization is supposed to propagate to + // Bulletin Chain via on-chain mechanics. The mobile waits 30s for + // visibility and swallows the failure, returning the slot key + // regardless. Our wait can therefore time out even on the happy + // path where the chain *will* catch up. The slot key + marker are + // still cached so the next run / `dot deploy` picks them up, and + // the funding + mapping steps below DO NOT depend on Bulletin + // authorization — they only need the product account on Asset + // Hub. Treat the Bulletin timeout as a soft failure, surface the + // faucet help, and continue. update(0, { status: "active", value: "checking…", valueTone: "muted" }); - let allowancesOk = false; + let accountSetupOk = true; try { const tags = PLAYGROUND_RESOURCES.map((r) => r.tag); const marked = await Promise.all(tags.map((t) => hasAllowance(env, address, t))); @@ -178,7 +193,6 @@ export function AccountSetup({ value: "already granted", valueTone: "muted", }); - allowancesOk = true; } else { update(0, { status: "active", @@ -197,16 +211,17 @@ export function AccountSetup({ if (cancelled) return; setPhonePrompt(null); const summary = summarizeOutcomes(outcomes, PLAYGROUND_RESOURCES); - const bulletinKey = extractSlotAccountKey(outcomes, "BulletInAllowance"); - if (bulletinKey) { - await waitForBulletinSlotAuthorization(client.bulletin, bulletinKey); - } + + // Persist every slot key the mobile returned BEFORE the + // Bulletin propagation wait — a `waitForBulletinSlotAuthorization` + // timeout below shouldn't discard a perfectly valid key. await storeSlotAccountKeysFromOutcomes(env, address, outcomes); // RFC-0010 allocation outcomes are independent: keep any // successful keys even if a sibling resource was denied. await Promise.all( summary.granted.map((r) => markAllowance(env, address, r.tag, "host")), ); + if (summary.rejected.length > 0 || summary.unavailable.length > 0) { const denied = [...summary.rejected, ...summary.unavailable] .map(describeResource) @@ -219,13 +234,38 @@ export function AccountSetup({ finish(false); return; } + + const bulletinKey = extractSlotAccountKey(outcomes, "BulletInAllowance"); + if (bulletinKey) { + try { + await waitForBulletinSlotAuthorization(client.bulletin, bulletinKey); + } catch (waitErr) { + // Soft failure: key + marker are cached above, so + // the next run / `dot deploy` will see them. The + // funding/mapping step doesn't need this, so we + // surface the help and keep going. + accountSetupOk = false; + update(0, { + status: "failed", + value: "approve on your Polkadot mobile app…", + error: + waitErr instanceof Error + ? waitErr.message + : bulletinAuthorizationHelp( + getSlotAccountAddress(bulletinKey), + ), + valueTone: "warning", + }); + } + } if (cancelled) return; - update(0, { - status: "ok", - value: `granted (${summary.granted.length})`, - valueTone: "muted", - }); - allowancesOk = true; + if (accountSetupOk) { + update(0, { + status: "ok", + value: `granted (${summary.granted.length})`, + valueTone: "muted", + }); + } } } catch (err) { setPhonePrompt(null); @@ -256,15 +296,6 @@ export function AccountSetup({ // covers the cold-start case the deploy preflight error message // ("Account is not mapped in Revive. Run `dot init`...") would // otherwise leave the user stuck on. - if (!allowancesOk) { - update(1, { - status: "skipped", - value: "skipped — allowances missing", - valueTone: "muted", - }); - finish(false); - return; - } update(1, { status: "active", value: "checking balance…", valueTone: "muted" }); try { const result = await topUpFromBulletinDev(client, address); @@ -299,7 +330,7 @@ export function AccountSetup({ return; } - finish(true); + finish(accountSetupOk); })(); // Cleanup is the SOLE owner of `session?.destroy()`. Calling destroy() diff --git a/src/commands/init/QrLogin.tsx b/src/commands/init/QrLogin.tsx index 46f5d02..0eea4b1 100644 --- a/src/commands/init/QrLogin.tsx +++ b/src/commands/init/QrLogin.tsx @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Row } from "../../utils/ui/theme/index.js"; import { waitForLogin, @@ -30,18 +30,22 @@ export function QrLogin({ onDone: (addresses: SessionAddresses | null) => void; }) { const [status, setStatus] = useState({ step: "waiting" }); + // Snapshot the SessionAddresses from the success status update so the + // `.then` handler below can hand them to the parent without reading + // QrLogin's React state. Calling `onDone` inside a `setStatus(updater)` + // would invoke the parent's `setAddresses` from within React's render + // phase — that's the "Cannot update a component while rendering a + // different component" warning we shipped accidentally in #188. + const addressesRef = useRef(null); useEffect(() => { - // `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; - }); + waitForLogin(login, (next) => { + setStatus(next); + if (next.step === "success") { + addressesRef.current = next.addresses; + } + }).then(() => { + onDone(addressesRef.current); }); }, []); diff --git a/src/config.ts b/src/config.ts index 1f14260..90b4d45 100644 --- a/src/config.ts +++ b/src/config.ts @@ -173,3 +173,13 @@ export const TERMINAL_METADATA_URL = /** Default build output directory — matches Vite and the interactive prompt default. */ export const DEFAULT_BUILD_DIR = "dist"; + +/** + * Web faucet for manually authorizing a Bulletin slot account when RFC-0010 + * People→Bulletin propagation hasn't reflected the mobile-side + * `Resources::claim_long_term_storage` claim by the time `dot init` polls. + * Surfaced verbatim in `bulletinAuthorizationHelp` so the user has a clear + * recovery path. Testnet-only; the Summit devnet bypasses this entirely. + */ +export const BULLETIN_AUTHORIZATION_URL = + "https://paritytech.github.io/polkadot-bulletin-chain/authorizations"; diff --git a/src/utils/allowances/bulletin.test.ts b/src/utils/allowances/bulletin.test.ts index f024943..cd1d396 100644 --- a/src/utils/allowances/bulletin.test.ts +++ b/src/utils/allowances/bulletin.test.ts @@ -24,7 +24,8 @@ vi.mock("@parity/product-sdk-bulletin", () => ({ checkAuthorization: checkAuthorizationMock, })); -import { hasUsableBulletinSlotAuthorization } from "./bulletin.js"; +import { BULLETIN_AUTHORIZATION_URL } from "../../config.js"; +import { bulletinAuthorizationHelp, hasUsableBulletinSlotAuthorization } from "./bulletin.js"; const KEY = secretFromSeed(new Uint8Array(32).fill(7)); @@ -66,3 +67,14 @@ describe("Bulletin allowance authorization", () => { await expect(hasUsableBulletinSlotAuthorization({} as any, KEY, 50)).resolves.toBe(false); }); }); + +describe("bulletinAuthorizationHelp", () => { + it("includes the faucet URL and the slot account SS58", () => { + const help = bulletinAuthorizationHelp("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"); + expect(help).toContain(BULLETIN_AUTHORIZATION_URL); + expect(help).toContain("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"); + // The user needs an actionable instruction, not just a URL drop — + // make sure the "re-run dot init" hint stays in the string. + expect(help).toMatch(/re-run.*dot init/i); + }); +}); diff --git a/src/utils/allowances/bulletin.ts b/src/utils/allowances/bulletin.ts index 1ee072c..45e8c48 100644 --- a/src/utils/allowances/bulletin.ts +++ b/src/utils/allowances/bulletin.ts @@ -15,7 +15,7 @@ import type { PolkadotSigner } from "polkadot-api"; import { checkAuthorization, type BulletinApi } from "@parity/product-sdk-bulletin"; -import type { Env } from "../../config.js"; +import { BULLETIN_AUTHORIZATION_URL, type Env } from "../../config.js"; import type { ResolvedSigner } from "../signer.js"; import { requestResourceAllocation, type OnExistingAllowancePolicy } from "./host.js"; import { markAllowance } from "./marker.js"; @@ -27,6 +27,18 @@ import { storeSlotAccountKey, } from "./slotKeys.js"; +/** + * Help string appended to every Bulletin-allowance error that comes from + * the chain-side authorization not being visible on Bulletin yet. The + * mobile may have submitted `Resources::claim_long_term_storage` on + * People successfully but the People→Bulletin propagation hasn't landed + * (or the chain rejected it silently). Surfacing the slot SS58 + the + * faucet URL gives the user a concrete recovery path on testnets. + */ +export function bulletinAuthorizationHelp(slotAccountAddress: string): string { + return `Open the Bulletin authorization faucet at ${BULLETIN_AUTHORIZATION_URL} and authorize account ${slotAccountAddress}, then re-run \`dot init\`.`; +} + export interface BulletinAllowanceSignerOptions { env: Env; ownerAddress: string; @@ -77,10 +89,11 @@ export async function waitForBulletinSlotAuthorization( await new Promise((resolve) => setTimeout(resolve, BULLETIN_AUTH_POLL_MS)); } + const help = bulletinAuthorizationHelp(address); throw new Error( lastAuthorized - ? `Bulletin allowance for ${address} is live but does not have enough quota.` - : `Mobile returned Bulletin allowance key ${address}, but it is not authorized on Bulletin yet.`, + ? `Bulletin allowance for ${address} is live but does not have enough quota. ${help}` + : `Mobile returned Bulletin allowance key ${address}, but it is not authorized on Bulletin yet. ${help}`, ); } @@ -104,7 +117,12 @@ export async function getBulletinAllowanceSigner({ return createSlotAccountSigner(cached); } if (!publishSigner.userSession) { - throw new Error("Cached Bulletin allowance key is not authorized. Run `dot init`."); + const slotAddress = getSlotAccountAddress(cached); + throw new Error( + `Cached Bulletin allowance key ${slotAddress} is not authorized. ${bulletinAuthorizationHelp( + slotAddress, + )}`, + ); } return await requestAndStoreBulletinAllowanceSigner({ env, @@ -164,12 +182,20 @@ export async function requestAndStoreBulletinAllowanceSigner({ throw new Error(`Bulletin allowance was not granted (${outcome}).`); } + // Persist the key BEFORE the propagation wait. If the wait throws + // (chain hasn't reflected the People-side claim yet), we still want + // the next `dot init` / `dot deploy` to find the cached key instead + // of forcing the user to re-pair from scratch. The mobile derived + // it from the user's root via the deterministic + // `//allowance//bulletin//` path, so the same key will + // be valid the moment the chain catches up. + await storeSlotAccountKey(env, ownerAddress, "BulletInAllowance", key); + await markAllowance(env, ownerAddress, "BulletInAllowance", "host"); + if (bulletinApi) { await waitForBulletinSlotAuthorization(bulletinApi, key, requiredBytes); } - await storeSlotAccountKey(env, ownerAddress, "BulletInAllowance", key); - await markAllowance(env, ownerAddress, "BulletInAllowance", "host"); return createSlotAccountSigner(key); } diff --git a/src/utils/allowances/slotKeys.test.ts b/src/utils/allowances/slotKeys.test.ts index 37ed88d..0230eae 100644 --- a/src/utils/allowances/slotKeys.test.ts +++ b/src/utils/allowances/slotKeys.test.ts @@ -86,6 +86,32 @@ describe("slot account key cache", () => { expect(await readSlotAccountKey("paseo-next-v2", ADDR, "BulletInAllowance")).toEqual(KEY); }); + it("preserves sibling slot keys when multiple resources are returned at once", async () => { + // Regression guard: the previous implementation looped via + // Promise.all(...storeSlotAccountKey) and each save read+wrote + // the file, so concurrent saves clobbered each other's writes + // and the second-returned sibling key would be dropped. The + // batched read-modify-write must keep both keys. + const otherKey = secretFromSeed(new Uint8Array(32).fill(13)); + const outcomes: AllocationOutcome[] = [ + { + tag: "Allocated", + value: { tag: "BulletInAllowance", value: { slotAccountKey: KEY } }, + }, + { + tag: "Allocated", + value: { tag: "StatementStoreAllowance", value: { slotAccountKey: otherKey } }, + }, + ]; + + await storeSlotAccountKeysFromOutcomes("paseo-next-v2", ADDR, outcomes); + + expect(await readSlotAccountKey("paseo-next-v2", ADDR, "BulletInAllowance")).toEqual(KEY); + expect(await readSlotAccountKey("paseo-next-v2", ADDR, "StatementStoreAllowance")).toEqual( + otherKey, + ); + }); + it("creates a signer from a raw slot account key", async () => { const signer = createSlotAccountSigner(KEY); diff --git a/src/utils/allowances/slotKeys.ts b/src/utils/allowances/slotKeys.ts index adc2d89..eb9636d 100644 --- a/src/utils/allowances/slotKeys.ts +++ b/src/utils/allowances/slotKeys.ts @@ -161,18 +161,36 @@ export async function storeSlotAccountKeysFromOutcomes( address: string, outcomes: AllocationOutcome[], ): Promise { - await Promise.all( - outcomes.map(async (outcome) => { - if (outcome.tag !== "Allocated") return; - const allocated = outcome.value as - | { tag?: ResourceTag; value?: { slotAccountKey?: Uint8Array } } - | undefined; - if (!allocated?.tag || !isSlotAccountResource(allocated.tag)) return; - const key = allocated.value?.slotAccountKey; - if (!(key instanceof Uint8Array)) return; - await storeSlotAccountKey(env, address, allocated.tag, key); - }), - ); + // Single read-modify-write so two slot keys returned in one call + // (e.g. BulletInAllowance + StatementStoreAllowance) can't race — + // the old `Promise.all([...storeSlotAccountKey])` pattern had each + // call load the file, mutate one resource, save the file; the + // saves would interleave and the second write would clobber the + // first slot key. + const file = await loadFile(); + let mutated = false; + + for (const outcome of outcomes) { + if (outcome.tag !== "Allocated") continue; + const allocated = outcome.value as + | { tag?: ResourceTag; value?: { slotAccountKey?: Uint8Array } } + | undefined; + if (!allocated?.tag || !isSlotAccountResource(allocated.tag)) continue; + const key = allocated.value?.slotAccountKey; + if (!(key instanceof Uint8Array)) continue; + + const envBucket = file.envs[env] ?? {}; + const addrBucket = envBucket[address] ?? {}; + addrBucket[allocated.tag] = { + slotAccountKey: toHex(normalizeSlotAccountKey(key)) as `0x${string}`, + storedAt: Date.now(), + }; + envBucket[address] = addrBucket; + file.envs[env] = envBucket; + mutated = true; + } + + if (mutated) await saveFile(file); } export function createSlotAccountSigner(slotAccountKey: Uint8Array): PolkadotSigner { From ec963c71f4d26372c596d347bb58a4a90d34a6fe Mon Sep 17 00:00:00 2001 From: UtkarshBhardwaj007 Date: Tue, 19 May 2026 21:28:14 +0100 Subject: [PATCH 2/2] review fixes: env-scoped faucet URL, accurate soft-failure copy, doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move `bulletinAuthorizationUrl` into `ChainConfig` so the Summit devnet / mainnet swap is a single-config-row change (was a top-level constant that violated the "all chain URLs live in CONFIGS" CLAUDE.md invariant). Populated for paseo-next-v2; `null` on envs without a public faucet. - `bulletinAuthorizationHelp(slot, url?)` reads the active env's `bulletinAuthorizationUrl` by default; renders a generic "propagation pending" message when no faucet is configured. - Fix misleading `dot init` soft-failure row copy: it was "approve on your Polkadot mobile app…" — but the user has just successfully approved, the failure is People→Bulletin propagation, not a pending mobile prompt. Now reads "Bulletin authorization pending" and the error line carries the full diagnostic via `describe(waitErr)`. - Drop the dead-code `waitErr instanceof Error ? msg : help(...)` branch — `waitForBulletinSlotAuthorization` always throws Error. - Single timestamp per `storeSlotAccountKeysFromOutcomes` batch instead of one per slot (these come from the same `requestResourceAllocation` round-trip, semantically one cohort). - Add the persist-before-wait invariant to CLAUDE.md under "Allowances / session" with the upstream root cause callout (XCM-relayed Bulletin allocation can fail silently as `LongTermStorageAllocationFailed`, same class as Mobile PR #582's PGAS fix not applied to storage). - Bulletin help tests cover both explicit-URL and no-URL branches and pin the literal faucet path so a future config rename can't silently produce a wrong user-facing URL. CLAUDE.md is 39,990 chars (back under the 40k Claude Code warning). 540 tests pass, format + license clean. --- CLAUDE.md | 1 + src/commands/init/AccountSetup.tsx | 17 ++++++-------- src/config.ts | 19 +++++++-------- src/utils/allowances/bulletin.test.ts | 34 +++++++++++++++++++++++---- src/utils/allowances/bulletin.ts | 24 +++++++++++++++---- src/utils/allowances/slotKeys.ts | 5 +++- 6 files changed, 69 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0aa405c..9ac5259 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,7 @@ These aren't self-evident from reading the code and have bitten us before. Treat - **`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. - **`requestResourceAllocation` lives in a CLI-local shim** (`src/utils/allowances/host.ts`). `@parity/product-sdk-terminal@0.2.1` does NOT yet re-export the RFC-0010 host call at the package root, but the underlying `UserSession` (from `@novasamatech/host-papp`) does — we call it directly via the raw session on `SessionHandle.userSession`. `@parity/product-sdk-host`'s `requestResourceAllocation` is the in-container variant (browser globals required) and won't work from the CLI. Replace the shim when product-sdk-terminal surfaces it externally. - **Allowance grant markers live at `~/.polkadot/allowances.json`** (`src/utils/allowances/marker.ts`), mode 0600, sibling to `accounts.json`. RFC-0010 has no on-chain query for allowance status, so we persist `{ env: { ss58Address: { resourceTag: { grantedAt, source } } } }` after a successful host grant. Slot-account private keys for Bulletin / Statement Store live separately in `~/.polkadot/allowance-keys.json` (`src/utils/allowances/slotKeys.ts`), also mode 0600. A marker alone isn't enough to skip `dot init` for slot resources — confirm the matching key exists too. Markers and keys are isolated per env. Keep `source: "host"` as the only value emitted from production code. +- **Slot keys persist BEFORE the Bulletin propagation wait** in `requestAndStoreBulletinAllowanceSigner`, so a cached key may exist while `TransactionStorage::Authorizations[Account()]` is still empty (mobile's `claim_long_term_storage` on People can succeed while the XCM-relayed Bulletin allocation fails silently with `LongTermStorageAllocationFailed`). Always check usability via `hasUsableBulletinSlotAuthorization`, never just `hasSlotAccountKey`. Failed waits surface `bulletinAuthorizationHelp(slot)` against the env's `bulletinAuthorizationUrl`. - **`dot init --yes` auto-runs at the end of `install.sh`** to skip the interactive QR-scan so non-interactive installers don't block. It installs prerequisites and prints "setup complete", then `install.sh` prints a hint to run `dot init` for the full mobile login. Dep-setup failures surface their exit code so CI runs don't silently pass. ### CLI surface boundaries diff --git a/src/commands/init/AccountSetup.tsx b/src/commands/init/AccountSetup.tsx index 925c432..76ebf0a 100644 --- a/src/commands/init/AccountSetup.tsx +++ b/src/commands/init/AccountSetup.tsx @@ -29,13 +29,11 @@ import { } from "../../utils/allowances/host.js"; import { hasAllowance, markAllowance } from "../../utils/allowances/marker.js"; import { - bulletinAuthorizationHelp, hasUsableBulletinSlotAuthorization, waitForBulletinSlotAuthorization, } from "../../utils/allowances/bulletin.js"; import { extractSlotAccountKey, - getSlotAccountAddress, hasSlotAccountKey, readSlotAccountKey, storeSlotAccountKeysFromOutcomes, @@ -243,17 +241,16 @@ export function AccountSetup({ // Soft failure: key + marker are cached above, so // the next run / `dot deploy` will see them. The // funding/mapping step doesn't need this, so we - // surface the help and keep going. + // surface the help and keep going. The user has + // already approved on their phone at this point + // — the problem is People→Bulletin propagation, + // not a pending mobile prompt, so the row label + // mustn't ask them to re-approve. accountSetupOk = false; update(0, { status: "failed", - value: "approve on your Polkadot mobile app…", - error: - waitErr instanceof Error - ? waitErr.message - : bulletinAuthorizationHelp( - getSlotAccountAddress(bulletinKey), - ), + value: "Bulletin authorization pending", + error: describe(waitErr), valueTone: "warning", }); } diff --git a/src/config.ts b/src/config.ts index 90b4d45..4dd9967 100644 --- a/src/config.ts +++ b/src/config.ts @@ -66,6 +66,14 @@ export interface ChainConfig { bulletinAuthorizeV2: boolean; /** Public faucet URL, or null when allowances replace the funder flow. */ faucetUrl: string | null; + /** + * Web faucet URL for manually authorizing a Bulletin slot account when + * RFC-0010 People→Bulletin propagation lags. Surfaced in + * `bulletinAuthorizationHelp` so the user has a recovery path on + * testnets. `null` on production / closed-devnet envs where allowances + * are pre-allocated and no manual path exists. + */ + bulletinAuthorizationUrl: string | null; } // Paseo Next v2 — the active env. DotNS contracts are owned by @@ -84,6 +92,7 @@ const PASEO_NEXT_V2: ChainConfig = { autoAccountMapping: true, bulletinAuthorizeV2: true, faucetUrl: null, + bulletinAuthorizationUrl: "https://paritytech.github.io/polkadot-bulletin-chain/authorizations", }; const CONFIGS: Partial> = { @@ -173,13 +182,3 @@ export const TERMINAL_METADATA_URL = /** Default build output directory — matches Vite and the interactive prompt default. */ export const DEFAULT_BUILD_DIR = "dist"; - -/** - * Web faucet for manually authorizing a Bulletin slot account when RFC-0010 - * People→Bulletin propagation hasn't reflected the mobile-side - * `Resources::claim_long_term_storage` claim by the time `dot init` polls. - * Surfaced verbatim in `bulletinAuthorizationHelp` so the user has a clear - * recovery path. Testnet-only; the Summit devnet bypasses this entirely. - */ -export const BULLETIN_AUTHORIZATION_URL = - "https://paritytech.github.io/polkadot-bulletin-chain/authorizations"; diff --git a/src/utils/allowances/bulletin.test.ts b/src/utils/allowances/bulletin.test.ts index cd1d396..acb6039 100644 --- a/src/utils/allowances/bulletin.test.ts +++ b/src/utils/allowances/bulletin.test.ts @@ -24,7 +24,7 @@ vi.mock("@parity/product-sdk-bulletin", () => ({ checkAuthorization: checkAuthorizationMock, })); -import { BULLETIN_AUTHORIZATION_URL } from "../../config.js"; +import { getChainConfig } from "../../config.js"; import { bulletinAuthorizationHelp, hasUsableBulletinSlotAuthorization } from "./bulletin.js"; const KEY = secretFromSeed(new Uint8Array(32).fill(7)); @@ -69,12 +69,36 @@ describe("Bulletin allowance authorization", () => { }); describe("bulletinAuthorizationHelp", () => { - it("includes the faucet URL and the slot account SS58", () => { - const help = bulletinAuthorizationHelp("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"); - expect(help).toContain(BULLETIN_AUTHORIZATION_URL); - expect(help).toContain("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"); + const ADDR = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; + + it("includes the explicitly-passed faucet URL + slot account SS58", () => { + const help = bulletinAuthorizationHelp(ADDR, "https://example.test/faucet"); + expect(help).toContain("https://example.test/faucet"); + expect(help).toContain(ADDR); // The user needs an actionable instruction, not just a URL drop — // make sure the "re-run dot init" hint stays in the string. expect(help).toMatch(/re-run.*dot init/i); }); + + it("falls back to a propagation-pending message when no faucet URL is configured", () => { + const help = bulletinAuthorizationHelp(ADDR, null); + // Mainnet / closed Summit devnet won't have a public faucet — the + // help must not invite users to a URL that doesn't apply. + expect(help).not.toMatch(/https?:\/\//); + expect(help).toContain(ADDR); + expect(help).toMatch(/propagation/i); + }); + + it("defaults to the active env's bulletinAuthorizationUrl when no URL passed", () => { + const help = bulletinAuthorizationHelp(ADDR); + const cfgUrl = getChainConfig().bulletinAuthorizationUrl; + if (cfgUrl) { + expect(help).toContain(cfgUrl); + // Pin the literal path component so a future rename of the + // constant can't silently produce a wrong user-facing URL. + expect(help).toMatch(/paritytech\.github\.io\/polkadot-bulletin-chain\/authorizations/); + } else { + expect(help).not.toMatch(/https?:\/\//); + } + }); }); diff --git a/src/utils/allowances/bulletin.ts b/src/utils/allowances/bulletin.ts index 45e8c48..8b982a1 100644 --- a/src/utils/allowances/bulletin.ts +++ b/src/utils/allowances/bulletin.ts @@ -15,7 +15,7 @@ import type { PolkadotSigner } from "polkadot-api"; import { checkAuthorization, type BulletinApi } from "@parity/product-sdk-bulletin"; -import { BULLETIN_AUTHORIZATION_URL, type Env } from "../../config.js"; +import { getChainConfig, type Env } from "../../config.js"; import type { ResolvedSigner } from "../signer.js"; import { requestResourceAllocation, type OnExistingAllowancePolicy } from "./host.js"; import { markAllowance } from "./marker.js"; @@ -32,11 +32,25 @@ import { * the chain-side authorization not being visible on Bulletin yet. The * mobile may have submitted `Resources::claim_long_term_storage` on * People successfully but the People→Bulletin propagation hasn't landed - * (or the chain rejected it silently). Surfacing the slot SS58 + the - * faucet URL gives the user a concrete recovery path on testnets. + * (or the chain rejected it silently with `LongTermStorageAllocationFailed`, + * which mobile's `.logFailure(...)` swallows). Surfacing the slot SS58 + + * the env's faucet URL (when one is configured) gives the user a concrete + * recovery path on testnets. On envs without a faucet (mainnet / closed + * Summit devnet — `bulletinAuthorizationUrl: null`) we fall back to a + * generic "propagation pending" message rather than pointing at a URL + * that doesn't apply. + * + * `faucetUrl` defaults to the active env's config so callers don't have + * to plumb it through; pass `null` explicitly to render the no-faucet + * variant, or override in tests for determinism. */ -export function bulletinAuthorizationHelp(slotAccountAddress: string): string { - return `Open the Bulletin authorization faucet at ${BULLETIN_AUTHORIZATION_URL} and authorize account ${slotAccountAddress}, then re-run \`dot init\`.`; +export function bulletinAuthorizationHelp( + slotAccountAddress: string, + faucetUrl: string | null = getChainConfig().bulletinAuthorizationUrl, +): string { + return faucetUrl + ? `Open the Bulletin authorization faucet at ${faucetUrl} and authorize account ${slotAccountAddress}, then re-run \`dot init\`.` + : `Bulletin allowance for ${slotAccountAddress} is not authorized on chain yet — wait a moment for People→Bulletin propagation, then re-run \`dot init\`.`; } export interface BulletinAllowanceSignerOptions { diff --git a/src/utils/allowances/slotKeys.ts b/src/utils/allowances/slotKeys.ts index eb9636d..fed4504 100644 --- a/src/utils/allowances/slotKeys.ts +++ b/src/utils/allowances/slotKeys.ts @@ -168,6 +168,9 @@ export async function storeSlotAccountKeysFromOutcomes( // saves would interleave and the second write would clobber the // first slot key. const file = await loadFile(); + // One timestamp for the whole batch — these keys all came from the same + // `requestResourceAllocation` round-trip and represent one cohort. + const storedAt = Date.now(); let mutated = false; for (const outcome of outcomes) { @@ -183,7 +186,7 @@ export async function storeSlotAccountKeysFromOutcomes( const addrBucket = envBucket[address] ?? {}; addrBucket[allocated.tag] = { slotAccountKey: toHex(normalizeSlotAccountKey(key)) as `0x${string}`, - storedAt: Date.now(), + storedAt, }; envBucket[address] = addrBucket; file.envs[env] = envBucket;