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/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 68387e7..76ebf0a 100644 --- a/src/commands/init/AccountSetup.tsx +++ b/src/commands/init/AccountSetup.tsx @@ -148,8 +148,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 +191,6 @@ export function AccountSetup({ value: "already granted", valueTone: "muted", }); - allowancesOk = true; } else { update(0, { status: "active", @@ -197,16 +209,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 +232,37 @@ 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. 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: "Bulletin authorization pending", + error: describe(waitErr), + 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 +293,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 +327,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..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> = { diff --git a/src/utils/allowances/bulletin.test.ts b/src/utils/allowances/bulletin.test.ts index f024943..acb6039 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 { getChainConfig } from "../../config.js"; +import { bulletinAuthorizationHelp, hasUsableBulletinSlotAuthorization } from "./bulletin.js"; const KEY = secretFromSeed(new Uint8Array(32).fill(7)); @@ -66,3 +67,38 @@ describe("Bulletin allowance authorization", () => { await expect(hasUsableBulletinSlotAuthorization({} as any, KEY, 50)).resolves.toBe(false); }); }); + +describe("bulletinAuthorizationHelp", () => { + 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 1ee072c..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 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"; @@ -27,6 +27,32 @@ 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 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, + 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 { env: Env; ownerAddress: string; @@ -77,10 +103,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 +131,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 +196,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..fed4504 100644 --- a/src/utils/allowances/slotKeys.ts +++ b/src/utils/allowances/slotKeys.ts @@ -161,18 +161,39 @@ 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(); + // 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) { + 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, + }; + envBucket[address] = addrBucket; + file.envs[env] = envBucket; + mutated = true; + } + + if (mutated) await saveFile(file); } export function createSlotAccountSigner(slotAccountKey: Uint8Array): PolkadotSigner {