diff --git a/.changeset/fix-init-product-account-display.md b/.changeset/fix-init-product-account-display.md new file mode 100644 index 0000000..b1afb7b --- /dev/null +++ b/.changeset/fix-init-product-account-display.md @@ -0,0 +1,5 @@ +--- +"playground-cli": patch +--- + +Keep Bulletin out of the mobile allowance request, show the Bulletin authorization faucet for the locally cached Bulletin account, and let `dot logout` recover from stale sessions missing the product-derivation root key. diff --git a/CLAUDE.md b/CLAUDE.md index 9ac5259..f07f1f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +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`. +- **Bulletin is not requested through mobile resource allocation in `dot init`.** Until product-sdk exposes the proper terminal host/preimage path, the CLI creates or reuses a locally cached Bulletin slot key and surfaces `bulletinAuthorizationHelp(slot)` against the env's `bulletinAuthorizationUrl`. Always check usability via `hasUsableBulletinSlotAuthorization`, never just `hasSlotAccountKey`. - **`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 76ebf0a..f0b9728 100644 --- a/src/commands/init/AccountSetup.tsx +++ b/src/commands/init/AccountSetup.tsx @@ -20,7 +20,7 @@ import { getConnection } from "../../utils/connection.js"; import { getSessionSigner, type SessionHandle } from "../../utils/auth.js"; import { topUpFromBulletinDev } from "../../utils/account/bulletinTopUp.js"; import { checkMapping, ensureMapped } from "../../utils/account/mapping.js"; -import { DEFAULT_ENV, PLAYGROUND_PRODUCT_ID } from "../../config.js"; +import { DEFAULT_ENV, PLAYGROUND_PRODUCT_ID, getChainConfig } from "../../config.js"; import { PLAYGROUND_RESOURCES, requestResourceAllocation, @@ -28,14 +28,11 @@ import { type AllocatableResource, } from "../../utils/allowances/host.js"; import { hasAllowance, markAllowance } from "../../utils/allowances/marker.js"; +import { hasUsableBulletinSlotAuthorization } from "../../utils/allowances/bulletin.js"; import { - hasUsableBulletinSlotAuthorization, - waitForBulletinSlotAuthorization, -} from "../../utils/allowances/bulletin.js"; -import { - extractSlotAccountKey, + getOrCreateSlotAccountKey, + getSlotAccountAddress, hasSlotAccountKey, - readSlotAccountKey, storeSlotAccountKeysFromOutcomes, } from "../../utils/allowances/slotKeys.js"; @@ -71,6 +68,10 @@ interface PhonePrompt { label: string; } +interface BulletinWarning { + slotAccountAddress: string; +} + /** Human-readable name for a resource tag, used in failure messages. */ function describeResource(r: AllocatableResource): string { switch (r.tag) { @@ -97,6 +98,8 @@ export function AccountSetup({ { label: "funding", status: "pending" }, ]); const [phonePrompt, setPhonePrompt] = useState(null); + const [bulletinWarning, setBulletinWarning] = useState(null); + const bulletinAuthorizationUrl = getChainConfig(DEFAULT_ENV).bulletinAuthorizationUrl; useEffect(() => { let cancelled = false; @@ -147,44 +150,49 @@ export function AccountSetup({ // ── Step 0: Resource allowances ───────────────────────────────── // 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. + // which is the same SS58 used everywhere else in this flow. Bulletin + // is intentionally not requested from mobile here: the CLI creates + // a local slot account and tells the user to authorize that account + // through the Bulletin faucet until product-sdk exposes the proper + // host-side preimage path. update(0, { status: "active", value: "checking…", valueTone: "muted" }); let accountSetupOk = true; try { const tags = PLAYGROUND_RESOURCES.map((r) => r.tag); const marked = await Promise.all(tags.map((t) => hasAllowance(env, address, t))); - const cachedBulletinKey = await readSlotAccountKey( + const slotKeys = await Promise.all([ + hasSlotAccountKey(env, address, "StatementStoreAllowance"), + ]); + const bulletinKey = await getOrCreateSlotAccountKey( env, address, "BulletInAllowance", ); - const slotKeys = await Promise.all([ - Promise.resolve(cachedBulletinKey !== null), - hasSlotAccountKey(env, address, "StatementStoreAllowance"), - ]); if (cancelled) return; - const cachedBulletinUsable = - cachedBulletinKey === null - ? false - : await hasUsableBulletinSlotAuthorization( - client.bulletin, - cachedBulletinKey, - ); - const allMarked = - marked.every(Boolean) && slotKeys.every(Boolean) && cachedBulletinUsable; + let bulletinUsable = false; + try { + bulletinUsable = await hasUsableBulletinSlotAuthorization( + client.bulletin, + bulletinKey, + 1, + ); + } catch { + bulletinUsable = false; + } + if (cancelled) return; + const slotAccountAddress = getSlotAccountAddress(bulletinKey); + setBulletinWarning( + bulletinUsable + ? null + : { + slotAccountAddress, + }, + ); + if (bulletinUsable) { + await markAllowance(env, address, "BulletInAllowance", "host"); + } + + const allMarked = marked.every(Boolean) && slotKeys.every(Boolean); if (allMarked) { update(0, { status: "ok", @@ -210,69 +218,41 @@ export function AccountSetup({ setPhonePrompt(null); const summary = summarizeOutcomes(outcomes, PLAYGROUND_RESOURCES); - // 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")), - ); + for (const resource of summary.granted) { + await markAllowance(env, address, resource.tag, "host"); + } if (summary.rejected.length > 0 || summary.unavailable.length > 0) { const denied = [...summary.rejected, ...summary.unavailable] .map(describeResource) .join(", "); + accountSetupOk = false; update(0, { status: "failed", error: `denied: ${denied}. Re-run \`dot init\` and approve on your phone.`, valueTone: "danger", }); - 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; - if (accountSetupOk) { + } else { update(0, { status: "ok", value: `granted (${summary.granted.length})`, valueTone: "muted", }); } + + if (cancelled) return; } } catch (err) { setPhonePrompt(null); + accountSetupOk = false; update(0, { status: "failed", error: describe(err), valueTone: "danger", }); - finish(false); - return; } // ── Step 1: Top up the product-derived account ────────────────── @@ -368,6 +348,35 @@ export function AccountSetup({ )} + {bulletinWarning && ( + + {bulletinAuthorizationUrl ? ( + <> + + Open the Bulletin authorization faucet at{" "} + {bulletinAuthorizationUrl} + + + and authorize account{" "} + {bulletinWarning.slotAccountAddress} + {", then re-run "} + dot init. + + + ) : ( + <> + + Bulletin allowance account{" "} + {bulletinWarning.slotAccountAddress} is not + authorized yet. + + + Re-run dot init after authorizing it. + + + )} + + )} ); } diff --git a/src/config.ts b/src/config.ts index 4dd9967..629b194 100644 --- a/src/config.ts +++ b/src/config.ts @@ -67,10 +67,9 @@ export interface ChainConfig { /** 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 + * Web faucet URL for manually authorizing the CLI's Bulletin slot account. + * 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; diff --git a/src/utils/allowances/bulletin.test.ts b/src/utils/allowances/bulletin.test.ts index acb6039..4057955 100644 --- a/src/utils/allowances/bulletin.test.ts +++ b/src/utils/allowances/bulletin.test.ts @@ -14,7 +14,10 @@ // limitations under the License. import { secretFromSeed } from "@scure/sr25519"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { checkAuthorizationMock } = vi.hoisted(() => ({ checkAuthorizationMock: vi.fn(), @@ -24,16 +27,39 @@ vi.mock("@parity/product-sdk-bulletin", () => ({ checkAuthorization: checkAuthorizationMock, })); +import { + bulletinAuthorizationHelp, + getBulletinAllowanceSigner, + hasUsableBulletinSlotAuthorization, +} from "./bulletin.js"; +import { readSlotAccountKey, storeSlotAccountKey } from "./slotKeys.js"; import { getChainConfig } from "../../config.js"; -import { bulletinAuthorizationHelp, hasUsableBulletinSlotAuthorization } from "./bulletin.js"; const KEY = secretFromSeed(new Uint8Array(32).fill(7)); +const ENV = "paseo-next-v2"; +const OWNER = "5Owner"; -beforeEach(() => { +let root: string | null = null; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "playground-cli-allowances-")); + process.env.POLKADOT_ROOT = root; checkAuthorizationMock.mockReset(); }); +afterEach(async () => { + delete process.env.POLKADOT_ROOT; + if (root) await rm(root, { recursive: true, force: true }); + root = null; +}); + describe("Bulletin allowance authorization", () => { + it("formats manual authorization help for slot-account recovery", () => { + expect(bulletinAuthorizationHelp("5Slot")).toBe( + "Open the Bulletin authorization faucet at https://paritytech.github.io/polkadot-bulletin-chain/authorizations and authorize account 5Slot, then re-run `dot init`.", + ); + }); + it("checks the slot account address derived from the returned private key", async () => { checkAuthorizationMock.mockResolvedValue({ authorized: true, @@ -66,6 +92,57 @@ describe("Bulletin allowance authorization", () => { }); await expect(hasUsableBulletinSlotAuthorization({} as any, KEY, 50)).resolves.toBe(false); }); + + it("uses a cached slot key when it has enough Bulletin authorization", async () => { + await storeSlotAccountKey(ENV, OWNER, "BulletInAllowance", KEY); + checkAuthorizationMock.mockResolvedValueOnce({ + authorized: true, + remainingTransactions: 1, + remainingBytes: 100n, + expiration: 1, + }); + + const signer = await getBulletinAllowanceSigner({ + env: ENV, + ownerAddress: OWNER, + publishSigner: { + source: "session", + address: OWNER, + signer: {} as any, + destroy() {}, + }, + bulletinApi: {} as any, + requiredBytes: 50, + }); + + expect(signer.publicKey).toHaveLength(32); + }); + + it("creates a local slot key and points to the faucet when it is not authorized", async () => { + checkAuthorizationMock.mockResolvedValueOnce({ + authorized: false, + remainingTransactions: 0, + remainingBytes: 0n, + expiration: 0, + }); + + await expect( + getBulletinAllowanceSigner({ + env: ENV, + ownerAddress: OWNER, + publishSigner: { + source: "session", + address: OWNER, + signer: {} as any, + destroy() {}, + }, + bulletinApi: {} as any, + requiredBytes: 50, + }), + ).rejects.toThrow(/Bulletin authorization faucet/); + + await expect(readSlotAccountKey(ENV, OWNER, "BulletInAllowance")).resolves.toHaveLength(64); + }); }); describe("bulletinAuthorizationHelp", () => { @@ -80,13 +157,13 @@ describe("bulletinAuthorizationHelp", () => { expect(help).toMatch(/re-run.*dot init/i); }); - it("falls back to a propagation-pending message when no faucet URL is configured", () => { + it("falls back to a no-faucet 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); + expect(help).toMatch(/not authorized/i); }); it("defaults to the active env's bulletinAuthorizationUrl when no URL passed", () => { diff --git a/src/utils/allowances/bulletin.ts b/src/utils/allowances/bulletin.ts index 8b982a1..1b43562 100644 --- a/src/utils/allowances/bulletin.ts +++ b/src/utils/allowances/bulletin.ts @@ -17,54 +17,28 @@ import type { PolkadotSigner } from "polkadot-api"; import { checkAuthorization, type BulletinApi } from "@parity/product-sdk-bulletin"; 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"; import { createSlotAccountSigner, - extractSlotAccountKey, + getOrCreateSlotAccountKey, getSlotAccountAddress, - readSlotAccountKey, - 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; - productId: string; publishSigner: ResolvedSigner; bulletinApi?: BulletinApi; requiredBytes?: number; - onRequest?: (policy: OnExistingAllowancePolicy) => void; } -const BULLETIN_AUTH_WAIT_MS = 75_000; -const BULLETIN_AUTH_POLL_MS = 3_000; +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 account ${slotAccountAddress} is not authorized yet. Re-run \`dot init\` after authorizing it.`; +} function hasUsableAuthorization( status: Awaited>, @@ -82,132 +56,40 @@ export async function hasUsableBulletinSlotAuthorization( slotAccountKey: Uint8Array, requiredBytes = 0, ): Promise { - const address = getSlotAccountAddress(slotAccountKey); - const status = await checkAuthorization(bulletinApi, address); + const status = await getBulletinSlotAuthorization(bulletinApi, slotAccountKey); return hasUsableAuthorization(status, requiredBytes); } -export async function waitForBulletinSlotAuthorization( +async function getBulletinSlotAuthorization( bulletinApi: BulletinApi, slotAccountKey: Uint8Array, - requiredBytes = 0, -): Promise { - const deadline = Date.now() + BULLETIN_AUTH_WAIT_MS; - const address = getSlotAccountAddress(slotAccountKey); - let lastAuthorized = false; - - while (Date.now() <= deadline) { - const status = await checkAuthorization(bulletinApi, address); - lastAuthorized = status.authorized; - if (hasUsableAuthorization(status, requiredBytes)) return; - 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. ${help}` - : `Mobile returned Bulletin allowance key ${address}, but it is not authorized on Bulletin yet. ${help}`, - ); +): Promise>> { + return await checkAuthorization(bulletinApi, getSlotAccountAddress(slotAccountKey)); } export async function getBulletinAllowanceSigner({ env, ownerAddress, - productId, publishSigner, bulletinApi, requiredBytes, - onRequest, }: BulletinAllowanceSignerOptions): Promise { // Local dev/SURI deploys are the explicit CI escape hatch: the caller // supplied a local key and owns making sure it has Bulletin allowance. if (publishSigner.source === "dev") return publishSigner.signer; - const cached = await readSlotAccountKey(env, ownerAddress, "BulletInAllowance"); - if (cached) { - if (!bulletinApi) return createSlotAccountSigner(cached); - if (await hasUsableBulletinSlotAuthorization(bulletinApi, cached, requiredBytes)) { - return createSlotAccountSigner(cached); - } - if (!publishSigner.userSession) { - const slotAddress = getSlotAccountAddress(cached); - throw new Error( - `Cached Bulletin allowance key ${slotAddress} is not authorized. ${bulletinAuthorizationHelp( - slotAddress, - )}`, - ); - } - return await requestAndStoreBulletinAllowanceSigner({ - env, - ownerAddress, - productId, - publishSigner, - bulletinApi, - requiredBytes, - policy: "Ignore", - onRequest, - }); - } - - if (!publishSigner.userSession) { - throw new Error("Bulletin allowance key missing. Run `dot init` and approve allowances."); - } - - return await requestAndStoreBulletinAllowanceSigner({ - env, - ownerAddress, - productId, - publishSigner, - bulletinApi, - requiredBytes, - policy: "Ignore", - onRequest, - }); -} - -export async function requestAndStoreBulletinAllowanceSigner({ - env, - ownerAddress, - productId, - publishSigner, - bulletinApi, - requiredBytes, - policy, - onRequest, -}: BulletinAllowanceSignerOptions & { - policy: OnExistingAllowancePolicy; -}): Promise { - if (publishSigner.source === "dev") return publishSigner.signer; - if (!publishSigner.userSession) { - throw new Error("Cannot request Bulletin allowance without an active mobile session."); - } - - onRequest?.(policy); - const outcomes = await requestResourceAllocation( - publishSigner.userSession, - productId, - [{ tag: "BulletInAllowance", value: undefined }], - policy, - ); - const key = extractSlotAccountKey(outcomes, "BulletInAllowance"); - if (!key) { - const outcome = outcomes[0]?.tag ?? "missing"; - throw new Error(`Bulletin allowance was not granted (${outcome}).`); - } + const key = await getOrCreateSlotAccountKey(env, ownerAddress, "BulletInAllowance"); - // 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) return createSlotAccountSigner(key); - if (bulletinApi) { - await waitForBulletinSlotAuthorization(bulletinApi, key, requiredBytes); + const status = await getBulletinSlotAuthorization(bulletinApi, key); + if (!hasUsableAuthorization(status, requiredBytes)) { + const address = getSlotAccountAddress(key); + throw new Error( + status.authorized + ? `Bulletin allowance for ${address} is live but does not have enough quota. ${bulletinAuthorizationHelp(address)}` + : `Bulletin allowance account ${address} is not authorized. ${bulletinAuthorizationHelp(address)}`, + ); } return createSlotAccountSigner(key); diff --git a/src/utils/allowances/host.test.ts b/src/utils/allowances/host.test.ts index 09deefc..ee51bf3 100644 --- a/src/utils/allowances/host.test.ts +++ b/src/utils/allowances/host.test.ts @@ -23,9 +23,9 @@ import { } from "./host.js"; describe("PLAYGROUND_RESOURCES", () => { - it("requests Bulletin + StatementStore + SmartContract by default", () => { + it("requests mobile-granted StatementStore + SmartContract resources by default", () => { const tags = PLAYGROUND_RESOURCES.map((r) => r.tag); - expect(tags).toContain("BulletInAllowance"); + expect(tags).not.toContain("BulletInAllowance"); expect(tags).toContain("StatementStoreAllowance"); expect(tags).toContain("SmartContractAllowance"); }); diff --git a/src/utils/allowances/host.ts b/src/utils/allowances/host.ts index b4dcb38..2f34b23 100644 --- a/src/utils/allowances/host.ts +++ b/src/utils/allowances/host.ts @@ -42,6 +42,9 @@ import type { UserSession } from "@parity/product-sdk-terminal"; * * StatementStoreAllowance — write to the SSS (host_chat, allowance ring). * BulletInAllowance — write to Bulletin (TransactionStorage.store). + * Not requested by `dot init` today; the CLI uses + * a locally cached slot key and asks the user to + * authorize it through the Bulletin faucet. * SmartContractAllowance — PGAS sponsoring for Revive contract calls. * The `value` is the derivation index of the * product account (0 for the default playground @@ -72,9 +75,8 @@ export type ResourceTag = AllocatableResource["tag"]; export type OnExistingAllowancePolicy = "Ignore" | "Increase"; -/** Default resource set for the playground product. */ +/** Default mobile-granted resource set for the playground product. */ export const PLAYGROUND_RESOURCES: AllocatableResource[] = [ - { tag: "BulletInAllowance", value: undefined }, { tag: "StatementStoreAllowance", value: undefined }, // derivation index 0 = playground42.dot's default product account. { tag: "SmartContractAllowance", value: 0 }, diff --git a/src/utils/allowances/slotKeys.test.ts b/src/utils/allowances/slotKeys.test.ts index 0230eae..aab3abc 100644 --- a/src/utils/allowances/slotKeys.test.ts +++ b/src/utils/allowances/slotKeys.test.ts @@ -22,6 +22,7 @@ import { _internal, createSlotAccountSigner, extractSlotAccountKey, + getOrCreateSlotAccountKey, hasSlotAccountKey, readSlotAccountKey, storeSlotAccountKey, @@ -31,6 +32,7 @@ import type { AllocationOutcome } from "./host.js"; const ADDR = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; const KEY = secretFromSeed(new Uint8Array(32).fill(7)); +const KEY_2 = secretFromSeed(new Uint8Array(32).fill(8)); let tempRoot: string; let originalPolkadotRoot: string | undefined; @@ -78,12 +80,19 @@ describe("slot account key cache", () => { tag: "Allocated", value: { tag: "BulletInAllowance", value: { slotAccountKey: KEY } }, }, + { + tag: "Allocated", + value: { tag: "StatementStoreAllowance", value: { slotAccountKey: KEY_2 } }, + }, { tag: "Allocated", value: { tag: "SmartContractAllowance", value: undefined } }, ]; expect(extractSlotAccountKey(outcomes, "BulletInAllowance")).toEqual(KEY); 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( + KEY_2, + ); }); it("preserves sibling slot keys when multiple resources are returned at once", async () => { @@ -118,4 +127,12 @@ describe("slot account key cache", () => { expect(signer.publicKey).toHaveLength(32); await expect(signer.signBytes(new Uint8Array([1, 2, 3]))).resolves.toHaveLength(64); }); + + it("creates and then reuses a local slot key when none is cached", async () => { + const first = await getOrCreateSlotAccountKey("paseo-next-v2", ADDR, "BulletInAllowance"); + const second = await getOrCreateSlotAccountKey("paseo-next-v2", ADDR, "BulletInAllowance"); + + expect(first).toHaveLength(64); + expect(second).toEqual(first); + }); }); diff --git a/src/utils/allowances/slotKeys.ts b/src/utils/allowances/slotKeys.ts index fed4504..3cbfccf 100644 --- a/src/utils/allowances/slotKeys.ts +++ b/src/utils/allowances/slotKeys.ts @@ -19,14 +19,16 @@ * This is intentionally small and isolated so it can be replaced by a * product-sdk-terminal host/preimage helper once the SDK owns terminal * allowance-key persistence. Until then the CLI is the Host for terminal - * sessions: it receives scoped allowance private keys from mobile, stores - * them locally, and uses them to sign Bulletin/SSS submissions. + * sessions: it stores scoped allowance private keys from mobile or locally + * generated slot keys that users authorize manually, then uses them to sign + * Bulletin/SSS submissions. */ +import { randomBytes } from "node:crypto"; import { promises as fs } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { getPublicKey, sign } from "@scure/sr25519"; +import { getPublicKey, secretFromSeed, sign } from "@scure/sr25519"; import { AccountId } from "polkadot-api"; import { toHex, fromHex } from "polkadot-api/utils"; import { getPolkadotSigner } from "polkadot-api/signer"; @@ -140,6 +142,18 @@ export async function storeSlotAccountKey( await saveFile(file); } +export async function getOrCreateSlotAccountKey( + env: Env, + address: string, + resource: SlotAccountResourceTag, +): Promise { + const existing = await readSlotAccountKey(env, address, resource); + if (existing) return existing; + const key = secretFromSeed(randomBytes(32)); + await storeSlotAccountKey(env, address, resource, key); + return key; +} + export function extractSlotAccountKey( outcomes: AllocationOutcome[], resource: SlotAccountResourceTag, diff --git a/src/utils/auth.test.ts b/src/utils/auth.test.ts index 5547740..ec7111e 100644 --- a/src/utils/auth.test.ts +++ b/src/utils/auth.test.ts @@ -33,6 +33,7 @@ import { } from "./auth.js"; import type { UserSession } from "@parity/product-sdk-terminal"; import { DAPP_ID } from "../config.js"; +import { INCOMPLETE_SESSION_MESSAGE } from "./sessionSigner.js"; describe("subscribe-before-assignment pattern", () => { /** @@ -424,4 +425,10 @@ describe("deriveSessionAddresses", () => { expect(a.productH160).not.toBe(b.productH160); expect(b.productH160).toMatch(/^0x[0-9a-f]{40}$/); }); + + it("reports stale sessions without a root account public key", () => { + expect(() => deriveSessionAddresses(fakeSession(new Uint8Array()))).toThrow( + INCOMPLETE_SESSION_MESSAGE, + ); + }); }); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 8aafdbb..c05cc98 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -47,6 +47,7 @@ import { import { createPlaygroundSessionSigner, derivePlaygroundProductPublicKey, + sessionRootPublicKey, } from "./sessionSigner.js"; /** How long we wait for the statement store to publish the pairing QR. */ @@ -108,7 +109,7 @@ function createPlaygroundSigner(session: UserSession): PolkadotSigner { * @internal */ export function deriveSessionAddresses(session: UserSession): SessionAddresses { - const rootBytes = new Uint8Array(session.rootAccountId); + const rootBytes = sessionRootPublicKey(session); const productPubkey = derivePlaygroundProductPublicKey(rootBytes, { productId: PLAYGROUND_PRODUCT_ID, derivationIndex: 0, @@ -120,6 +121,21 @@ export function deriveSessionAddresses(session: UserSession): SessionAddresses { }; } +function sessionRemoteAddress(session: UserSession): string | null { + const raw = (session as { remoteAccount?: { accountId?: Uint8Array } }).remoteAccount + ?.accountId; + const accountId = raw ? new Uint8Array(raw) : new Uint8Array(); + return accountId.length === 32 ? ss58Encode(accountId) : null; +} + +function sessionLogoutAddress(session: UserSession): string { + try { + return deriveSessionAddresses(session).productAddress; + } catch { + return sessionRemoteAddress(session) ?? "(stored session)"; + } +} + export type ConnectResult = | { kind: "existing"; address: string; addresses: SessionAddresses } | { kind: "qr"; qrCode: string; login: LoginHandle }; @@ -394,7 +410,7 @@ export async function findSession(): Promise { return null; } const session = sessions[0]; - const address = deriveSessionAddresses(session).productAddress; + const address = sessionLogoutAddress(session); return { adapter, address, session }; } diff --git a/src/utils/deploy/playground.test.ts b/src/utils/deploy/playground.test.ts index 0747932..5440394 100644 --- a/src/utils/deploy/playground.test.ts +++ b/src/utils/deploy/playground.test.ts @@ -18,21 +18,13 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -const { - captureWarningMock, - withSpanMock, - bulletinStorageSigner, - getBulletinAllowanceSignerMock, - requestAndStoreBulletinAllowanceSignerMock, -} = vi.hoisted(() => ({ - captureWarningMock: vi.fn(), - withSpanMock: vi.fn(async (_op: string, _name: string, _attrs: any, fn: any) => fn()), - bulletinStorageSigner: { __signer: "bulletin-allowance" }, - getBulletinAllowanceSignerMock: vi.fn(async () => ({ __signer: "bulletin-allowance" })), - requestAndStoreBulletinAllowanceSignerMock: vi.fn(async () => ({ - __signer: "bulletin-allowance-refreshed", - })), -})); +const { captureWarningMock, withSpanMock, bulletinStorageSigner, getBulletinAllowanceSignerMock } = + vi.hoisted(() => ({ + captureWarningMock: vi.fn(), + withSpanMock: vi.fn(async (_op: string, _name: string, _attrs: any, fn: any) => fn()), + bulletinStorageSigner: { __signer: "bulletin-allowance" }, + getBulletinAllowanceSignerMock: vi.fn(async () => ({ __signer: "bulletin-allowance" })), + })); // Mock the metadata upload path so we never actually touch the network. // The mock returns a fake CID that publish() treats as the metadata CID. @@ -46,8 +38,6 @@ vi.mock("@parity/product-sdk-tx", () => ({ })); vi.mock("../allowances/bulletin.js", () => ({ getBulletinAllowanceSigner: (options: unknown) => getBulletinAllowanceSignerMock(options), - requestAndStoreBulletinAllowanceSigner: (options: unknown) => - requestAndStoreBulletinAllowanceSignerMock(options), isInvalidPaymentError: (err: unknown) => String(err).includes("Payment"), })); vi.mock("polkadot-api", () => ({ @@ -112,10 +102,6 @@ beforeEach(() => { withSpanMock.mockClear(); getBulletinAllowanceSignerMock.mockClear(); getBulletinAllowanceSignerMock.mockResolvedValue(bulletinStorageSigner); - requestAndStoreBulletinAllowanceSignerMock.mockClear(); - requestAndStoreBulletinAllowanceSignerMock.mockResolvedValue({ - __signer: "bulletin-allowance-refreshed", - }); vi.mocked(submitAndWatch).mockClear(); vi.mocked(submitAndWatch).mockResolvedValue({ ok: true, @@ -456,7 +442,7 @@ describe("publishToPlayground", () => { expect(ops).toContain("cli.deploy.playground.registry-publish"); }); - it("refreshes Bulletin allowance once when metadata upload fails with Invalid Payment", async () => { + it("re-checks Bulletin allowance once when metadata upload fails with Invalid Payment", async () => { vi.mocked(submitAndWatch) .mockRejectedValueOnce(new Error('{"type":"Invalid","value":{"type":"Payment"}}')) .mockResolvedValueOnce({ ok: true, block: { hash: "0x1", number: 1, index: 0 } }); @@ -468,9 +454,7 @@ describe("publishToPlayground", () => { cwd: undefined, }); - expect(requestAndStoreBulletinAllowanceSignerMock).toHaveBeenCalledWith( - expect.objectContaining({ policy: "Increase" }), - ); + expect(getBulletinAllowanceSignerMock).toHaveBeenCalledTimes(2); expect(submitAndWatch).toHaveBeenCalledTimes(2); }); diff --git a/src/utils/deploy/playground.ts b/src/utils/deploy/playground.ts index 6a815f7..3ab12a0 100644 --- a/src/utils/deploy/playground.ts +++ b/src/utils/deploy/playground.ts @@ -40,13 +40,9 @@ import { calculateCid } from "@parity/product-sdk-bulletin"; import { submitAndWatch, withRetry } from "@parity/product-sdk-tx"; import { getRegistryContract } from "../registry.js"; import { getConnection } from "../connection.js"; -import { getChainConfig, PLAYGROUND_PRODUCT_ID, type Env } from "../../config.js"; +import { getChainConfig, type Env } from "../../config.js"; import { captureWarning, withSpan, errorMessage } from "../../telemetry.js"; -import { - getBulletinAllowanceSigner, - isInvalidPaymentError, - requestAndStoreBulletinAllowanceSigner, -} from "../allowances/bulletin.js"; +import { getBulletinAllowanceSigner, isInvalidPaymentError } from "../allowances/bulletin.js"; import type { ResolvedSigner } from "../signer.js"; import type { DeployLogEvent } from "./progress.js"; @@ -238,15 +234,9 @@ export async function publishToPlayground( let storageSigner = await getBulletinAllowanceSigner({ env: options.env ?? getChainConfig().env, ownerAddress: options.publishSigner.address, - productId: PLAYGROUND_PRODUCT_ID, publishSigner: options.publishSigner, bulletinApi, requiredBytes: metadataBytes.length, - onRequest: () => - options.onLogEvent?.({ - kind: "info", - message: "Requesting Bulletin storage allowance…", - }), }); try { await withRetry(() => submitAndWatch(storeTx, storageSigner)); @@ -256,16 +246,14 @@ export async function publishToPlayground( } options.onLogEvent?.({ kind: "info", - message: "Refreshing Bulletin storage allowance…", + message: "Checking Bulletin storage allowance…", }); - storageSigner = await requestAndStoreBulletinAllowanceSigner({ + storageSigner = await getBulletinAllowanceSigner({ env: options.env ?? getChainConfig().env, ownerAddress: options.publishSigner.address, - productId: PLAYGROUND_PRODUCT_ID, publishSigner: options.publishSigner, bulletinApi, requiredBytes: metadataBytes.length, - policy: "Increase", }); await withRetry(() => submitAndWatch(storeTx, storageSigner)); } diff --git a/src/utils/sessionSigner.test.ts b/src/utils/sessionSigner.test.ts index 938cc31..b8d15ba 100644 --- a/src/utils/sessionSigner.test.ts +++ b/src/utils/sessionSigner.test.ts @@ -103,6 +103,20 @@ describe("init / deploy / playground-app account equivalence", () => { expect(cliAddress).not.toEqual(walletAddress); }); + test("reports stale sessions without a root account public key", () => { + const session = { + ...fakeSession(DEV_PHRASE), + rootAccountId: new Uint8Array(), + } as UserSession; + + expect(() => + createPlaygroundSessionSigner(session, { + productId: PLAYGROUND_PRODUCT_ID, + derivationIndex: 0, + }), + ).toThrow('Stored login session is missing the root account public key. Run "dot logout"'); + }); + test("PLAYGROUND_PRODUCT_ID matches the playground-app's default dotNsId", () => { // The deployed playground-app defaults to PLAYGROUND_DOTNS_ID = "playground.dot" // (see playground-app/src/config.ts::defaultDotNsId for the non-localhost path). diff --git a/src/utils/sessionSigner.ts b/src/utils/sessionSigner.ts index 7abdd61..9d80a52 100644 --- a/src/utils/sessionSigner.ts +++ b/src/utils/sessionSigner.ts @@ -64,6 +64,18 @@ export interface ProductAccountRef { derivationIndex: number; } +export const INCOMPLETE_SESSION_MESSAGE = + 'Stored login session is missing the root account public key. Run "dot logout" and then "dot init" to pair again.'; + +export function sessionRootPublicKey(session: UserSession): Uint8Array { + const rootAccountId = (session as { rootAccountId?: Uint8Array }).rootAccountId; + const publicKey = rootAccountId ? new Uint8Array(rootAccountId) : new Uint8Array(); + if (publicKey.length !== 32) { + throw new Error(INCOMPLETE_SESSION_MESSAGE); + } + return publicKey; +} + /** * Soft-derive the product account public key off a wallet root. * @@ -133,7 +145,7 @@ export function createPlaygroundSessionSigner( // 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 publicKey = derivePlaygroundProductPublicKey(sessionRootPublicKey(session), ref); const address = ss58Encode(publicKey); // Wire-shape identifier passed to host-papp's `signPayload` / `signRaw`.