From 3b73614241834974f5d9ec80dfeed276f51b0b7d Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Tue, 19 May 2026 14:11:21 -0400 Subject: [PATCH 1/6] Fix RFC10 allowance init handling --- .../fix-init-product-account-display.md | 5 + src/commands/init/AccountSetup.tsx | 59 ++++---- src/commands/init/IdentityLines.tsx | 10 +- src/commands/init/identityLine.test.ts | 30 ++-- src/commands/init/identityLine.ts | 17 +-- src/utils/allowances/bulletin.test.ts | 134 +++++++++++++++++- src/utils/allowances/bulletin.ts | 22 ++- src/utils/allowances/slotKeys.test.ts | 8 ++ src/utils/allowances/slotKeys.ts | 36 +++-- 9 files changed, 238 insertions(+), 83 deletions(-) create mode 100644 .changeset/fix-init-product-account-display.md diff --git a/.changeset/fix-init-product-account-display.md b/.changeset/fix-init-product-account-display.md new file mode 100644 index 0000000..e757b38 --- /dev/null +++ b/.changeset/fix-init-product-account-display.md @@ -0,0 +1,5 @@ +--- +"playground-cli": patch +--- + +Fix `dot init` so the product account row shows the actual signer account used by deploy, and make Bulletin allowance setup tolerate delayed authorization propagation without skipping product-account funding. diff --git a/src/commands/init/AccountSetup.tsx b/src/commands/init/AccountSetup.tsx index 68387e7..2da4848 100644 --- a/src/commands/init/AccountSetup.tsx +++ b/src/commands/init/AccountSetup.tsx @@ -149,7 +149,7 @@ export function AccountSetup({ // (host-papp's `productAccountId = [PLAYGROUND_PRODUCT_ID, 0]`), // which is the same SS58 used everywhere else in this flow. 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 +178,6 @@ export function AccountSetup({ value: "already granted", valueTone: "muted", }); - allowancesOk = true; } else { update(0, { status: "active", @@ -197,45 +196,56 @@ export function AccountSetup({ if (cancelled) return; setPhonePrompt(null); const summary = summarizeOutcomes(outcomes, PLAYGROUND_RESOURCES); + await storeSlotAccountKeysFromOutcomes(env, address, outcomes); + // RFC-0010 allocation outcomes are independent: keep any + // successful keys even if a sibling resource was denied or + // Bulletin propagation is still catching up. + for (const resource of summary.granted) { + await markAllowance(env, address, resource.tag, "host"); + } + const bulletinKey = extractSlotAccountKey(outcomes, "BulletInAllowance"); + let bulletinReady = true; if (bulletinKey) { - await waitForBulletinSlotAuthorization(client.bulletin, bulletinKey); + try { + await waitForBulletinSlotAuthorization(client.bulletin, bulletinKey); + } catch (err) { + bulletinReady = false; + accountSetupOk = false; + update(0, { + status: "failed", + error: describe(err), + valueTone: "danger", + }); + } } - 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) .join(", "); + accountSetupOk = false; update(0, { status: "failed", error: `denied: ${denied}. Re-run \`dot init\` and approve on your phone.`, valueTone: "danger", }); - finish(false); - return; + } else if (bulletinReady) { + update(0, { + status: "ok", + value: `granted (${summary.granted.length})`, + valueTone: "muted", + }); } if (cancelled) return; - update(0, { - status: "ok", - value: `granted (${summary.granted.length})`, - valueTone: "muted", - }); - allowancesOk = true; } } 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 ────────────────── @@ -256,15 +266,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 +300,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/IdentityLines.tsx b/src/commands/init/IdentityLines.tsx index 0600ae3..04e44c0 100644 --- a/src/commands/init/IdentityLines.tsx +++ b/src/commands/init/IdentityLines.tsx @@ -26,11 +26,11 @@ import { productAccountDisplay } from "./identityLine.js"; * * Both the SS58 and the 0x H160 are printed in full so the user can copy * them directly. The username lookup is async (queries People parachain) - * and has a 10s timeout inside `lookupUsername`; the product account is - * synchronous (pure sr25519 soft derivation). A `(looking up...)` - * placeholder renders while the lookup is in flight; failures and missing - * identities fall through to the relevant fallback strings from - * `formatUsernameLine`. + * and has a 10s timeout inside `lookupUsername`; the product account row is + * synchronous and mirrors the signer address used by deploy. A + * `(looking up...)` placeholder renders while the lookup is in flight; + * failures and missing identities fall through to the relevant fallback + * strings from `formatUsernameLine`. */ export function IdentityLines({ address }: { address: string }) { const [username, setUsername] = useState({ kind: "loading" }); diff --git a/src/commands/init/identityLine.test.ts b/src/commands/init/identityLine.test.ts index 2a077e2..37fd100 100644 --- a/src/commands/init/identityLine.test.ts +++ b/src/commands/init/identityLine.test.ts @@ -14,30 +14,31 @@ // limitations under the License. import { describe, expect, it } from "vitest"; -import { ss58Encode } from "@parity/product-sdk-address"; +import { deriveH160, ss58Encode } from "@parity/product-sdk-address"; import { productAccountAddresses, productAccountDisplay } from "./identityLine.js"; -// A deterministic, all-zero root public key gives a stable derived product -// account. The exact bytes don't matter; we only assert that the helper -// produces a non-empty SS58 + valid 0x-prefixed H160 and that the display -// helper renders both in the expected "ss58 (h160)" shape. -const ZERO_ROOT_SS58 = ss58Encode(new Uint8Array(32)); +// A deterministic, all-zero product public key gives a stable display account. +// The exact bytes don't matter; we only assert that the helper preserves the +// signer SS58, derives its H160, and renders both in the expected +// "ss58 (h160)" shape. +const ZERO_PRODUCT_PUBLIC_KEY = new Uint8Array(32); +const ZERO_PRODUCT_SS58 = ss58Encode(ZERO_PRODUCT_PUBLIC_KEY); describe("productAccountAddresses", () => { - it("derives a non-empty SS58 + a 42-char H160 from a root SS58", () => { - const { ss58, h160 } = productAccountAddresses(ZERO_ROOT_SS58); - expect(typeof ss58).toBe("string"); + it("preserves the signer SS58 and derives its H160", () => { + const { ss58, h160 } = productAccountAddresses(ZERO_PRODUCT_SS58); + expect(ss58).toBe(ZERO_PRODUCT_SS58); // Substrate SS58 addresses for a 32-byte pubkey are 47–48 chars on // the default ss58Format=42 prefix. Anything shorter would mean // we'd accidentally re-introduced truncation. expect(ss58.length).toBeGreaterThanOrEqual(47); expect(ss58).toMatch(/^[1-9A-HJ-NP-Za-km-z]+$/); - expect(h160).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(h160).toBe(deriveH160(ZERO_PRODUCT_PUBLIC_KEY)); }); - it("is deterministic for the same root SS58", () => { - const a = productAccountAddresses(ZERO_ROOT_SS58); - const b = productAccountAddresses(ZERO_ROOT_SS58); + it("is deterministic for the same product SS58", () => { + const a = productAccountAddresses(ZERO_PRODUCT_SS58); + const b = productAccountAddresses(ZERO_PRODUCT_SS58); expect(a.ss58).toBe(b.ss58); expect(a.h160).toBe(b.h160); }); @@ -45,9 +46,10 @@ describe("productAccountAddresses", () => { describe("productAccountDisplay", () => { it("renders 'ss58 (h160)' with the full SS58 + full 0x-prefixed H160", () => { - const display = productAccountDisplay(ZERO_ROOT_SS58); + const display = productAccountDisplay(ZERO_PRODUCT_SS58); const match = display.match(/^([1-9A-HJ-NP-Za-km-z]+) \((0x[0-9a-fA-F]{40})\)$/); expect(match).not.toBeNull(); + expect(match?.[1]).toBe(ZERO_PRODUCT_SS58); // No ellipses anywhere — the whole point of the change is that we // print the full address so the user can copy it directly. expect(display).not.toContain("..."); diff --git a/src/commands/init/identityLine.ts b/src/commands/init/identityLine.ts index 0bd8672..d565833 100644 --- a/src/commands/init/identityLine.ts +++ b/src/commands/init/identityLine.ts @@ -20,25 +20,22 @@ * `completion.ts` next to `InitScreen.tsx` for the same pattern). */ -import { deriveH160, ss58Decode, ss58Encode } from "@parity/product-sdk-address"; -import { deriveProductAccountPublicKey } from "@parity/product-sdk-keys"; -import { PLAYGROUND_PRODUCT_ID } from "../../config.js"; +import { deriveH160, ss58Decode } from "@parity/product-sdk-address"; export interface ProductAccountAddresses { ss58: string; h160: `0x${string}`; } -export function productAccountAddresses(rootAccountSs58: string): ProductAccountAddresses { - const { publicKey } = ss58Decode(rootAccountSs58); - const productPubkey = deriveProductAccountPublicKey(publicKey, PLAYGROUND_PRODUCT_ID, 0); +export function productAccountAddresses(productAccountSs58: string): ProductAccountAddresses { + const { publicKey } = ss58Decode(productAccountSs58); return { - ss58: ss58Encode(productPubkey), - h160: deriveH160(productPubkey), + ss58: productAccountSs58, + h160: deriveH160(publicKey), }; } -export function productAccountDisplay(rootAccountSs58: string): string { - const { ss58, h160 } = productAccountAddresses(rootAccountSs58); +export function productAccountDisplay(productAccountSs58: string): string { + const { ss58, h160 } = productAccountAddresses(productAccountSs58); return `${ss58} (${h160})`; } diff --git a/src/utils/allowances/bulletin.test.ts b/src/utils/allowances/bulletin.test.ts index f024943..02528b3 100644 --- a/src/utils/allowances/bulletin.test.ts +++ b/src/utils/allowances/bulletin.test.ts @@ -14,22 +14,58 @@ // 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(), -})); +const { checkAuthorizationMock, requestResourceAllocationMock, markAllowanceMock } = vi.hoisted( + () => ({ + checkAuthorizationMock: vi.fn(), + requestResourceAllocationMock: vi.fn(), + markAllowanceMock: vi.fn(), + }), +); vi.mock("@parity/product-sdk-bulletin", () => ({ checkAuthorization: checkAuthorizationMock, })); -import { hasUsableBulletinSlotAuthorization } from "./bulletin.js"; +vi.mock("./host.js", () => ({ + requestResourceAllocation: requestResourceAllocationMock, +})); + +vi.mock("./marker.js", () => ({ + markAllowance: markAllowanceMock, +})); + +import { + getBulletinAllowanceSigner, + hasUsableBulletinSlotAuthorization, + requestAndStoreBulletinAllowanceSigner, +} from "./bulletin.js"; +import { readSlotAccountKey, storeSlotAccountKey } from "./slotKeys.js"; const KEY = secretFromSeed(new Uint8Array(32).fill(7)); +const KEY_2 = secretFromSeed(new Uint8Array(32).fill(8)); +const ENV = "paseo-next-v2"; +const OWNER = "5Owner"; +const PRODUCT_ID = "playground.dot"; -beforeEach(() => { +let root: string | null = null; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "playground-cli-allowances-")); + process.env.POLKADOT_ROOT = root; checkAuthorizationMock.mockReset(); + requestResourceAllocationMock.mockReset(); + markAllowanceMock.mockReset(); +}); + +afterEach(async () => { + delete process.env.POLKADOT_ROOT; + if (root) await rm(root, { recursive: true, force: true }); + root = null; }); describe("Bulletin allowance authorization", () => { @@ -65,4 +101,90 @@ describe("Bulletin allowance authorization", () => { }); await expect(hasUsableBulletinSlotAuthorization({} as any, KEY, 50)).resolves.toBe(false); }); + + it("stores the returned slot key before waiting for Bulletin propagation", async () => { + requestResourceAllocationMock.mockResolvedValueOnce([ + { + tag: "Allocated", + value: { + tag: "BulletInAllowance", + value: { slotAccountKey: KEY }, + }, + }, + ]); + checkAuthorizationMock.mockRejectedValueOnce(new Error("rpc unavailable")); + + await expect( + requestAndStoreBulletinAllowanceSigner({ + env: ENV, + ownerAddress: OWNER, + productId: PRODUCT_ID, + publishSigner: { + source: "session", + address: OWNER, + userSession: {} as any, + signer: {} as any, + destroy() {}, + }, + bulletinApi: {} as any, + policy: "Ignore", + }), + ).rejects.toThrow("rpc unavailable"); + + await expect(readSlotAccountKey(ENV, OWNER, "BulletInAllowance")).resolves.toEqual(KEY); + expect(markAllowanceMock).not.toHaveBeenCalled(); + }); + + it("requests an increased allocation when a cached slot key is live but out of quota", async () => { + await storeSlotAccountKey(ENV, OWNER, "BulletInAllowance", KEY); + const userSession = {}; + const requestedPolicies: string[] = []; + + checkAuthorizationMock + .mockResolvedValueOnce({ + authorized: true, + remainingTransactions: 0, + remainingBytes: 100n, + expiration: 1, + }) + .mockResolvedValueOnce({ + authorized: true, + remainingTransactions: 1, + remainingBytes: 100n, + expiration: 1, + }); + requestResourceAllocationMock.mockResolvedValueOnce([ + { + tag: "Allocated", + value: { + tag: "BulletInAllowance", + value: { slotAccountKey: KEY_2 }, + }, + }, + ]); + + await getBulletinAllowanceSigner({ + env: ENV, + ownerAddress: OWNER, + productId: PRODUCT_ID, + publishSigner: { + source: "session", + address: OWNER, + userSession: userSession as any, + signer: {} as any, + destroy() {}, + }, + bulletinApi: {} as any, + requiredBytes: 50, + onRequest: (policy) => requestedPolicies.push(policy), + }); + + expect(requestedPolicies).toEqual(["Increase"]); + expect(requestResourceAllocationMock).toHaveBeenCalledWith( + userSession, + PRODUCT_ID, + [{ tag: "BulletInAllowance", value: undefined }], + "Increase", + ); + }); }); diff --git a/src/utils/allowances/bulletin.ts b/src/utils/allowances/bulletin.ts index 1ee072c..8d05751 100644 --- a/src/utils/allowances/bulletin.ts +++ b/src/utils/allowances/bulletin.ts @@ -37,7 +37,7 @@ export interface BulletinAllowanceSignerOptions { onRequest?: (policy: OnExistingAllowancePolicy) => void; } -const BULLETIN_AUTH_WAIT_MS = 75_000; +const BULLETIN_AUTH_WAIT_MS = 300_000; const BULLETIN_AUTH_POLL_MS = 3_000; function hasUsableAuthorization( @@ -56,11 +56,17 @@ 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); } +async function getBulletinSlotAuthorization( + bulletinApi: BulletinApi, + slotAccountKey: Uint8Array, +): Promise>> { + return await checkAuthorization(bulletinApi, getSlotAccountAddress(slotAccountKey)); +} + export async function waitForBulletinSlotAuthorization( bulletinApi: BulletinApi, slotAccountKey: Uint8Array, @@ -80,7 +86,7 @@ export async function waitForBulletinSlotAuthorization( 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.`, + : `Mobile returned Bulletin allowance key ${address}, but it is not authorized on Bulletin yet. This is usually delayed allowance propagation; re-run \`dot init\` after a minute.`, ); } @@ -100,7 +106,8 @@ export async function getBulletinAllowanceSigner({ const cached = await readSlotAccountKey(env, ownerAddress, "BulletInAllowance"); if (cached) { if (!bulletinApi) return createSlotAccountSigner(cached); - if (await hasUsableBulletinSlotAuthorization(bulletinApi, cached, requiredBytes)) { + const status = await getBulletinSlotAuthorization(bulletinApi, cached); + if (hasUsableAuthorization(status, requiredBytes)) { return createSlotAccountSigner(cached); } if (!publishSigner.userSession) { @@ -113,7 +120,7 @@ export async function getBulletinAllowanceSigner({ publishSigner, bulletinApi, requiredBytes, - policy: "Ignore", + policy: status.authorized ? "Increase" : "Ignore", onRequest, }); } @@ -164,11 +171,12 @@ export async function requestAndStoreBulletinAllowanceSigner({ throw new Error(`Bulletin allowance was not granted (${outcome}).`); } + await storeSlotAccountKey(env, ownerAddress, "BulletInAllowance", key); + 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..00db4fd 100644 --- a/src/utils/allowances/slotKeys.test.ts +++ b/src/utils/allowances/slotKeys.test.ts @@ -31,6 +31,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 +79,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("creates a signer from a raw slot account key", async () => { diff --git a/src/utils/allowances/slotKeys.ts b/src/utils/allowances/slotKeys.ts index adc2d89..e821046 100644 --- a/src/utils/allowances/slotKeys.ts +++ b/src/utils/allowances/slotKeys.ts @@ -161,18 +161,30 @@ 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); - }), - ); + const file = await loadFile(); + const envBucket = file.envs[env] ?? {}; + const addrBucket = envBucket[address] ?? {}; + let changed = 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; + addrBucket[allocated.tag] = { + slotAccountKey: toHex(normalizeSlotAccountKey(key)) as `0x${string}`, + storedAt: Date.now(), + }; + changed = true; + } + + if (!changed) return; + envBucket[address] = addrBucket; + file.envs[env] = envBucket; + await saveFile(file); } export function createSlotAccountSigner(slotAccountKey: Uint8Array): PolkadotSigner { From 1b5653b1fe650145d2d48da5fc5f4f5d1a229302 Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Tue, 19 May 2026 14:37:40 -0400 Subject: [PATCH 2/6] Handle stale login sessions --- .changeset/fix-init-product-account-display.md | 2 +- src/utils/auth.ts | 15 ++++++++++++++- src/utils/sessionSigner.test.ts | 14 ++++++++++++++ src/utils/sessionSigner.ts | 14 +++++++++++++- 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/.changeset/fix-init-product-account-display.md b/.changeset/fix-init-product-account-display.md index e757b38..aeebbae 100644 --- a/.changeset/fix-init-product-account-display.md +++ b/.changeset/fix-init-product-account-display.md @@ -2,4 +2,4 @@ "playground-cli": patch --- -Fix `dot init` so the product account row shows the actual signer account used by deploy, and make Bulletin allowance setup tolerate delayed authorization propagation without skipping product-account funding. +Fix `dot init` so the product account row shows the actual signer account used by deploy, make Bulletin allowance setup tolerate delayed authorization propagation without skipping product-account funding, and let `dot logout` recover from stale sessions missing the product-derivation root key. diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 37b8941..d6dd975 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -68,6 +68,19 @@ function sessionSigningAddress(session: UserSession): string { return ss58Encode(createPlaygroundSigner(session).publicKey); } +function sessionRemoteAddress(session: UserSession): string | null { + const accountId = new Uint8Array(session.remoteAccount.accountId); + return accountId.length === 32 ? ss58Encode(accountId) : null; +} + +function sessionLogoutAddress(session: UserSession): string { + try { + return sessionSigningAddress(session); + } catch { + return sessionRemoteAddress(session) ?? "(stored session)"; + } +} + export type ConnectResult = | { kind: "existing"; address: string } | { kind: "qr"; qrCode: string; login: LoginHandle }; @@ -324,7 +337,7 @@ export async function findSession(): Promise { return null; } const session = sessions[0]; - const address = sessionSigningAddress(session); + const address = sessionLogoutAddress(session); return { adapter, address, session }; } 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 6b1535e..7f56dda 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; +} + /** * Identifiers whose payload PAPI may populate but the PJS adapter doesn't * recognize. Mirrors `RELAXED_SIGNED_EXTENSIONS` in the polkadot-app sample. @@ -114,7 +126,7 @@ export function createPlaygroundSessionSigner( // Algorithm parity with mobile/desktop is locked by the frozen vectors in // `@parity/product-sdk-keys`'s `product-account.test.ts`. const publicKey = deriveProductAccountPublicKey( - new Uint8Array(session.rootAccountId), + sessionRootPublicKey(session), ref.productId, ref.derivationIndex, ); From 8b9afa21fefb61c43bc1b380cbcdae87d8ae253f Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Tue, 19 May 2026 14:47:27 -0400 Subject: [PATCH 3/6] Avoid waiting for Bulletin propagation during init --- src/commands/init/AccountSetup.tsx | 32 ++---------------------------- src/utils/allowances/bulletin.ts | 2 +- 2 files changed, 3 insertions(+), 31 deletions(-) diff --git a/src/commands/init/AccountSetup.tsx b/src/commands/init/AccountSetup.tsx index 2da4848..d0f15f5 100644 --- a/src/commands/init/AccountSetup.tsx +++ b/src/commands/init/AccountSetup.tsx @@ -29,11 +29,6 @@ import { } from "../../utils/allowances/host.js"; import { hasAllowance, markAllowance } from "../../utils/allowances/marker.js"; import { - hasUsableBulletinSlotAuthorization, - waitForBulletinSlotAuthorization, -} from "../../utils/allowances/bulletin.js"; -import { - extractSlotAccountKey, hasSlotAccountKey, readSlotAccountKey, storeSlotAccountKeysFromOutcomes, @@ -163,15 +158,7 @@ export function AccountSetup({ 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; + const allMarked = marked.every(Boolean) && slotKeys.every(Boolean); if (allMarked) { update(0, { status: "ok", @@ -204,21 +191,6 @@ export function AccountSetup({ await markAllowance(env, address, resource.tag, "host"); } - const bulletinKey = extractSlotAccountKey(outcomes, "BulletInAllowance"); - let bulletinReady = true; - if (bulletinKey) { - try { - await waitForBulletinSlotAuthorization(client.bulletin, bulletinKey); - } catch (err) { - bulletinReady = false; - accountSetupOk = false; - update(0, { - status: "failed", - error: describe(err), - valueTone: "danger", - }); - } - } if (summary.rejected.length > 0 || summary.unavailable.length > 0) { const denied = [...summary.rejected, ...summary.unavailable] .map(describeResource) @@ -229,7 +201,7 @@ export function AccountSetup({ error: `denied: ${denied}. Re-run \`dot init\` and approve on your phone.`, valueTone: "danger", }); - } else if (bulletinReady) { + } else { update(0, { status: "ok", value: `granted (${summary.granted.length})`, diff --git a/src/utils/allowances/bulletin.ts b/src/utils/allowances/bulletin.ts index 8d05751..bc5080a 100644 --- a/src/utils/allowances/bulletin.ts +++ b/src/utils/allowances/bulletin.ts @@ -37,7 +37,7 @@ export interface BulletinAllowanceSignerOptions { onRequest?: (policy: OnExistingAllowancePolicy) => void; } -const BULLETIN_AUTH_WAIT_MS = 300_000; +const BULLETIN_AUTH_WAIT_MS = 75_000; const BULLETIN_AUTH_POLL_MS = 3_000; function hasUsableAuthorization( From 96e043af73303e9d1959146952705689c1b3bda8 Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Tue, 19 May 2026 14:56:16 -0400 Subject: [PATCH 4/6] Detect unauthorized Bulletin allowance keys in init --- .../fix-init-product-account-display.md | 2 +- src/commands/init/AccountSetup.tsx | 41 ++++++++++++++++++- src/config.ts | 4 ++ src/utils/allowances/bulletin.test.ts | 7 ++++ src/utils/allowances/bulletin.ts | 12 ++++-- 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/.changeset/fix-init-product-account-display.md b/.changeset/fix-init-product-account-display.md index aeebbae..3127907 100644 --- a/.changeset/fix-init-product-account-display.md +++ b/.changeset/fix-init-product-account-display.md @@ -2,4 +2,4 @@ "playground-cli": patch --- -Fix `dot init` so the product account row shows the actual signer account used by deploy, make Bulletin allowance setup tolerate delayed authorization propagation without skipping product-account funding, and let `dot logout` recover from stale sessions missing the product-derivation root key. +Fix `dot init` so the product account row shows the actual signer account used by deploy, make Bulletin allowance setup tolerate delayed authorization propagation without skipping product-account funding, detect cached Bulletin allowance keys that are not authorized on-chain, and let `dot logout` recover from stale sessions missing the product-derivation root key. diff --git a/src/commands/init/AccountSetup.tsx b/src/commands/init/AccountSetup.tsx index d0f15f5..f729a42 100644 --- a/src/commands/init/AccountSetup.tsx +++ b/src/commands/init/AccountSetup.tsx @@ -29,6 +29,12 @@ import { } from "../../utils/allowances/host.js"; import { hasAllowance, markAllowance } from "../../utils/allowances/marker.js"; import { + bulletinAuthorizationHelp, + hasUsableBulletinSlotAuthorization, +} from "../../utils/allowances/bulletin.js"; +import { + extractSlotAccountKey, + getSlotAccountAddress, hasSlotAccountKey, readSlotAccountKey, storeSlotAccountKeysFromOutcomes, @@ -158,7 +164,15 @@ export function AccountSetup({ hasSlotAccountKey(env, address, "StatementStoreAllowance"), ]); if (cancelled) return; - const allMarked = marked.every(Boolean) && slotKeys.every(Boolean); + const cachedBulletinUsable = + cachedBulletinKey === null + ? false + : await hasUsableBulletinSlotAuthorization( + client.bulletin, + cachedBulletinKey, + ); + const allMarked = + marked.every(Boolean) && slotKeys.every(Boolean) && cachedBulletinUsable; if (allMarked) { update(0, { status: "ok", @@ -191,14 +205,37 @@ export function AccountSetup({ await markAllowance(env, address, resource.tag, "host"); } + const bulletinKey = + extractSlotAccountKey(outcomes, "BulletInAllowance") ?? cachedBulletinKey; + const bulletinDenied = + summary.rejected.some((r) => r.tag === "BulletInAllowance") || + summary.unavailable.some((r) => r.tag === "BulletInAllowance"); + const bulletinReady = + bulletinKey === null + ? !bulletinDenied + : await hasUsableBulletinSlotAuthorization( + client.bulletin, + bulletinKey, + ); if (summary.rejected.length > 0 || summary.unavailable.length > 0) { const denied = [...summary.rejected, ...summary.unavailable] .map(describeResource) .join(", "); + const bulletinHint = + bulletinDenied && bulletinKey + ? ` ${bulletinAuthorizationHelp(getSlotAccountAddress(bulletinKey))}` + : ""; + accountSetupOk = false; + update(0, { + status: "failed", + error: `denied: ${denied}. Re-run \`dot init\` and approve on your phone.${bulletinHint}`, + valueTone: "danger", + }); + } else if (!bulletinReady && bulletinKey) { accountSetupOk = false; update(0, { status: "failed", - error: `denied: ${denied}. Re-run \`dot init\` and approve on your phone.`, + error: bulletinAuthorizationHelp(getSlotAccountAddress(bulletinKey)), valueTone: "danger", }); } else { diff --git a/src/config.ts b/src/config.ts index 1f14260..a28288c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -171,5 +171,9 @@ export const PLAYGROUND_PRODUCT_ID = "playground.dot"; export const TERMINAL_METADATA_URL = "https://gist.githubusercontent.com/ReinhardHatko/1967dd3f4afe78683cc0ba14d6ec8744/raw/c1625eb7ed7671b7e09a3fa2a25998dde33c70b8/metadata.json"; +/** Manual Bulletin authorization page for slot-account allowance recovery. */ +export const BULLETIN_AUTHORIZATION_URL = + "https://paritytech.github.io/polkadot-bulletin-chain/authorizations"; + /** Default build output directory — matches Vite and the interactive prompt default. */ export const DEFAULT_BUILD_DIR = "dist"; diff --git a/src/utils/allowances/bulletin.test.ts b/src/utils/allowances/bulletin.test.ts index 02528b3..329b65d 100644 --- a/src/utils/allowances/bulletin.test.ts +++ b/src/utils/allowances/bulletin.test.ts @@ -40,6 +40,7 @@ vi.mock("./marker.js", () => ({ })); import { + bulletinAuthorizationHelp, getBulletinAllowanceSigner, hasUsableBulletinSlotAuthorization, requestAndStoreBulletinAllowanceSigner, @@ -69,6 +70,12 @@ afterEach(async () => { }); describe("Bulletin allowance authorization", () => { + it("formats manual authorization help for slot-account recovery", () => { + expect(bulletinAuthorizationHelp("5Slot")).toBe( + "Authorize Bulletin allowance account 5Slot at https://paritytech.github.io/polkadot-bulletin-chain/authorizations, then re-run `dot init`.", + ); + }); + it("checks the slot account address derived from the returned private key", async () => { checkAuthorizationMock.mockResolvedValue({ authorized: true, diff --git a/src/utils/allowances/bulletin.ts b/src/utils/allowances/bulletin.ts index bc5080a..5e98116 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"; @@ -40,6 +40,10 @@ export interface BulletinAllowanceSignerOptions { const BULLETIN_AUTH_WAIT_MS = 75_000; const BULLETIN_AUTH_POLL_MS = 3_000; +export function bulletinAuthorizationHelp(slotAccountAddress: string): string { + return `Authorize Bulletin allowance account ${slotAccountAddress} at ${BULLETIN_AUTHORIZATION_URL}, then re-run \`dot init\`.`; +} + function hasUsableAuthorization( status: Awaited>, requiredBytes = 0, @@ -86,7 +90,7 @@ export async function waitForBulletinSlotAuthorization( 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. This is usually delayed allowance propagation; re-run \`dot init\` after a minute.`, + : `Mobile returned Bulletin allowance key ${address}, but it is not authorized on Bulletin yet. ${bulletinAuthorizationHelp(address)}`, ); } @@ -111,7 +115,9 @@ export async function getBulletinAllowanceSigner({ return createSlotAccountSigner(cached); } if (!publishSigner.userSession) { - throw new Error("Cached Bulletin allowance key is not authorized. Run `dot init`."); + throw new Error( + `Cached Bulletin allowance key is not authorized. ${bulletinAuthorizationHelp(getSlotAccountAddress(cached))}`, + ); } return await requestAndStoreBulletinAllowanceSigner({ env, From dae3eaa56ffaf6baef12974997eb5d372ae33b6d Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Tue, 19 May 2026 15:35:26 -0400 Subject: [PATCH 5/6] Only mark Bulletin allowance after authorization --- src/commands/init/AccountSetup.tsx | 16 +++++++++++++++- src/utils/allowances/bulletin.test.ts | 2 +- src/utils/allowances/bulletin.ts | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/commands/init/AccountSetup.tsx b/src/commands/init/AccountSetup.tsx index f729a42..150b21d 100644 --- a/src/commands/init/AccountSetup.tsx +++ b/src/commands/init/AccountSetup.tsx @@ -154,6 +154,7 @@ export function AccountSetup({ try { const tags = PLAYGROUND_RESOURCES.map((r) => r.tag); const marked = await Promise.all(tags.map((t) => hasAllowance(env, address, t))); + const markedByTag = new Map(tags.map((tag, i) => [tag, marked[i]])); const cachedBulletinKey = await readSlotAccountKey( env, address, @@ -170,10 +171,18 @@ export function AccountSetup({ : await hasUsableBulletinSlotAuthorization( client.bulletin, cachedBulletinKey, + 1, ); const allMarked = - marked.every(Boolean) && slotKeys.every(Boolean) && cachedBulletinUsable; + PLAYGROUND_RESOURCES.filter((r) => r.tag !== "BulletInAllowance").every((r) => + markedByTag.get(r.tag), + ) && + slotKeys.every(Boolean) && + cachedBulletinUsable; if (allMarked) { + if (!markedByTag.get("BulletInAllowance") && cachedBulletinKey) { + await markAllowance(env, address, "BulletInAllowance", "host"); + } update(0, { status: "ok", value: "already granted", @@ -202,6 +211,7 @@ export function AccountSetup({ // successful keys even if a sibling resource was denied or // Bulletin propagation is still catching up. for (const resource of summary.granted) { + if (resource.tag === "BulletInAllowance") continue; await markAllowance(env, address, resource.tag, "host"); } @@ -216,6 +226,7 @@ export function AccountSetup({ : await hasUsableBulletinSlotAuthorization( client.bulletin, bulletinKey, + 1, ); if (summary.rejected.length > 0 || summary.unavailable.length > 0) { const denied = [...summary.rejected, ...summary.unavailable] @@ -239,6 +250,9 @@ export function AccountSetup({ valueTone: "danger", }); } else { + if (bulletinKey) { + await markAllowance(env, address, "BulletInAllowance", "host"); + } update(0, { status: "ok", value: `granted (${summary.granted.length})`, diff --git a/src/utils/allowances/bulletin.test.ts b/src/utils/allowances/bulletin.test.ts index 329b65d..6d82e80 100644 --- a/src/utils/allowances/bulletin.test.ts +++ b/src/utils/allowances/bulletin.test.ts @@ -72,7 +72,7 @@ afterEach(async () => { describe("Bulletin allowance authorization", () => { it("formats manual authorization help for slot-account recovery", () => { expect(bulletinAuthorizationHelp("5Slot")).toBe( - "Authorize Bulletin allowance account 5Slot at https://paritytech.github.io/polkadot-bulletin-chain/authorizations, then re-run `dot init`.", + "Open the Bulletin authorization faucet at https://paritytech.github.io/polkadot-bulletin-chain/authorizations and authorize account 5Slot, then re-run `dot init`.", ); }); diff --git a/src/utils/allowances/bulletin.ts b/src/utils/allowances/bulletin.ts index 5e98116..4aaa66a 100644 --- a/src/utils/allowances/bulletin.ts +++ b/src/utils/allowances/bulletin.ts @@ -41,7 +41,7 @@ const BULLETIN_AUTH_WAIT_MS = 75_000; const BULLETIN_AUTH_POLL_MS = 3_000; export function bulletinAuthorizationHelp(slotAccountAddress: string): string { - return `Authorize Bulletin allowance account ${slotAccountAddress} at ${BULLETIN_AUTHORIZATION_URL}, then re-run \`dot init\`.`; + return `Open the Bulletin authorization faucet at ${BULLETIN_AUTHORIZATION_URL} and authorize account ${slotAccountAddress}, then re-run \`dot init\`.`; } function hasUsableAuthorization( From 3dc13c239f20e972ec612f2886cacfede5900586 Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Tue, 19 May 2026 15:56:45 -0400 Subject: [PATCH 6/6] Use manual Bulletin authorization during init --- .../fix-init-product-account-display.md | 2 +- src/commands/init/AccountSetup.tsx | 109 +++++++-------- src/utils/allowances/bulletin.test.ts | 128 ++++++------------ src/utils/allowances/bulletin.ts | 119 ++-------------- src/utils/allowances/host.test.ts | 4 +- src/utils/allowances/host.ts | 6 +- src/utils/allowances/slotKeys.test.ts | 9 ++ src/utils/allowances/slotKeys.ts | 20 ++- src/utils/deploy/playground.test.ts | 34 ++--- src/utils/deploy/playground.ts | 20 +-- 10 files changed, 145 insertions(+), 306 deletions(-) diff --git a/.changeset/fix-init-product-account-display.md b/.changeset/fix-init-product-account-display.md index 3127907..08cae2d 100644 --- a/.changeset/fix-init-product-account-display.md +++ b/.changeset/fix-init-product-account-display.md @@ -2,4 +2,4 @@ "playground-cli": patch --- -Fix `dot init` so the product account row shows the actual signer account used by deploy, make Bulletin allowance setup tolerate delayed authorization propagation without skipping product-account funding, detect cached Bulletin allowance keys that are not authorized on-chain, and let `dot logout` recover from stale sessions missing the product-derivation root key. +Fix `dot init` so the product account row shows the actual signer account used by deploy, 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/src/commands/init/AccountSetup.tsx b/src/commands/init/AccountSetup.tsx index 150b21d..6b06812 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 { BULLETIN_AUTHORIZATION_URL, DEFAULT_ENV, PLAYGROUND_PRODUCT_ID } from "../../config.js"; import { PLAYGROUND_RESOURCES, requestResourceAllocation, @@ -28,15 +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 { - bulletinAuthorizationHelp, - hasUsableBulletinSlotAuthorization, -} from "../../utils/allowances/bulletin.js"; -import { - extractSlotAccountKey, + getOrCreateSlotAccountKey, getSlotAccountAddress, hasSlotAccountKey, - readSlotAccountKey, storeSlotAccountKeysFromOutcomes, } from "../../utils/allowances/slotKeys.js"; @@ -72,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) { @@ -98,6 +98,7 @@ export function AccountSetup({ { label: "funding", status: "pending" }, ]); const [phonePrompt, setPhonePrompt] = useState(null); + const [bulletinWarning, setBulletinWarning] = useState(null); useEffect(() => { let cancelled = false; @@ -154,35 +155,40 @@ export function AccountSetup({ try { const tags = PLAYGROUND_RESOURCES.map((r) => r.tag); const marked = await Promise.all(tags.map((t) => hasAllowance(env, address, t))); - const markedByTag = new Map(tags.map((tag, i) => [tag, marked[i]])); - 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, - 1, - ); - const allMarked = - PLAYGROUND_RESOURCES.filter((r) => r.tag !== "BulletInAllowance").every((r) => - markedByTag.get(r.tag), - ) && - 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) { - if (!markedByTag.get("BulletInAllowance") && cachedBulletinKey) { - await markAllowance(env, address, "BulletInAllowance", "host"); - } update(0, { status: "ok", value: "already granted", @@ -211,48 +217,20 @@ export function AccountSetup({ // successful keys even if a sibling resource was denied or // Bulletin propagation is still catching up. for (const resource of summary.granted) { - if (resource.tag === "BulletInAllowance") continue; await markAllowance(env, address, resource.tag, "host"); } - const bulletinKey = - extractSlotAccountKey(outcomes, "BulletInAllowance") ?? cachedBulletinKey; - const bulletinDenied = - summary.rejected.some((r) => r.tag === "BulletInAllowance") || - summary.unavailable.some((r) => r.tag === "BulletInAllowance"); - const bulletinReady = - bulletinKey === null - ? !bulletinDenied - : await hasUsableBulletinSlotAuthorization( - client.bulletin, - bulletinKey, - 1, - ); if (summary.rejected.length > 0 || summary.unavailable.length > 0) { const denied = [...summary.rejected, ...summary.unavailable] .map(describeResource) .join(", "); - const bulletinHint = - bulletinDenied && bulletinKey - ? ` ${bulletinAuthorizationHelp(getSlotAccountAddress(bulletinKey))}` - : ""; - accountSetupOk = false; - update(0, { - status: "failed", - error: `denied: ${denied}. Re-run \`dot init\` and approve on your phone.${bulletinHint}`, - valueTone: "danger", - }); - } else if (!bulletinReady && bulletinKey) { accountSetupOk = false; update(0, { status: "failed", - error: bulletinAuthorizationHelp(getSlotAccountAddress(bulletinKey)), + error: `denied: ${denied}. Re-run \`dot init\` and approve on your phone.`, valueTone: "danger", }); } else { - if (bulletinKey) { - await markAllowance(env, address, "BulletInAllowance", "host"); - } update(0, { status: "ok", value: `granted (${summary.granted.length})`, @@ -364,6 +342,19 @@ export function AccountSetup({ )} + {bulletinWarning && ( + + + Open the Bulletin authorization faucet at{" "} + {BULLETIN_AUTHORIZATION_URL} + + + and authorize account {bulletinWarning.slotAccountAddress} + {", then re-run "} + dot init. + + + )} ); } diff --git a/src/utils/allowances/bulletin.test.ts b/src/utils/allowances/bulletin.test.ts index 6d82e80..74870c4 100644 --- a/src/utils/allowances/bulletin.test.ts +++ b/src/utils/allowances/bulletin.test.ts @@ -19,39 +19,24 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { checkAuthorizationMock, requestResourceAllocationMock, markAllowanceMock } = vi.hoisted( - () => ({ - checkAuthorizationMock: vi.fn(), - requestResourceAllocationMock: vi.fn(), - markAllowanceMock: vi.fn(), - }), -); +const { checkAuthorizationMock } = vi.hoisted(() => ({ + checkAuthorizationMock: vi.fn(), +})); vi.mock("@parity/product-sdk-bulletin", () => ({ checkAuthorization: checkAuthorizationMock, })); -vi.mock("./host.js", () => ({ - requestResourceAllocation: requestResourceAllocationMock, -})); - -vi.mock("./marker.js", () => ({ - markAllowance: markAllowanceMock, -})); - import { bulletinAuthorizationHelp, getBulletinAllowanceSigner, hasUsableBulletinSlotAuthorization, - requestAndStoreBulletinAllowanceSigner, } from "./bulletin.js"; import { readSlotAccountKey, storeSlotAccountKey } from "./slotKeys.js"; const KEY = secretFromSeed(new Uint8Array(32).fill(7)); -const KEY_2 = secretFromSeed(new Uint8Array(32).fill(8)); const ENV = "paseo-next-v2"; const OWNER = "5Owner"; -const PRODUCT_ID = "playground.dot"; let root: string | null = null; @@ -59,8 +44,6 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "playground-cli-allowances-")); process.env.POLKADOT_ROOT = root; checkAuthorizationMock.mockReset(); - requestResourceAllocationMock.mockReset(); - markAllowanceMock.mockReset(); }); afterEach(async () => { @@ -109,89 +92,54 @@ describe("Bulletin allowance authorization", () => { await expect(hasUsableBulletinSlotAuthorization({} as any, KEY, 50)).resolves.toBe(false); }); - it("stores the returned slot key before waiting for Bulletin propagation", async () => { - requestResourceAllocationMock.mockResolvedValueOnce([ - { - tag: "Allocated", - value: { - tag: "BulletInAllowance", - value: { slotAccountKey: KEY }, - }, - }, - ]); - checkAuthorizationMock.mockRejectedValueOnce(new Error("rpc unavailable")); - - await expect( - requestAndStoreBulletinAllowanceSigner({ - env: ENV, - ownerAddress: OWNER, - productId: PRODUCT_ID, - publishSigner: { - source: "session", - address: OWNER, - userSession: {} as any, - signer: {} as any, - destroy() {}, - }, - bulletinApi: {} as any, - policy: "Ignore", - }), - ).rejects.toThrow("rpc unavailable"); - - await expect(readSlotAccountKey(ENV, OWNER, "BulletInAllowance")).resolves.toEqual(KEY); - expect(markAllowanceMock).not.toHaveBeenCalled(); - }); - - it("requests an increased allocation when a cached slot key is live but out of quota", async () => { + it("uses a cached slot key when it has enough Bulletin authorization", async () => { await storeSlotAccountKey(ENV, OWNER, "BulletInAllowance", KEY); - const userSession = {}; - const requestedPolicies: string[] = []; - - checkAuthorizationMock - .mockResolvedValueOnce({ - authorized: true, - remainingTransactions: 0, - remainingBytes: 100n, - expiration: 1, - }) - .mockResolvedValueOnce({ - authorized: true, - remainingTransactions: 1, - remainingBytes: 100n, - expiration: 1, - }); - requestResourceAllocationMock.mockResolvedValueOnce([ - { - tag: "Allocated", - value: { - tag: "BulletInAllowance", - value: { slotAccountKey: KEY_2 }, - }, - }, - ]); + checkAuthorizationMock.mockResolvedValueOnce({ + authorized: true, + remainingTransactions: 1, + remainingBytes: 100n, + expiration: 1, + }); - await getBulletinAllowanceSigner({ + const signer = await getBulletinAllowanceSigner({ env: ENV, ownerAddress: OWNER, - productId: PRODUCT_ID, publishSigner: { source: "session", address: OWNER, - userSession: userSession as any, signer: {} as any, destroy() {}, }, bulletinApi: {} as any, requiredBytes: 50, - onRequest: (policy) => requestedPolicies.push(policy), }); - expect(requestedPolicies).toEqual(["Increase"]); - expect(requestResourceAllocationMock).toHaveBeenCalledWith( - userSession, - PRODUCT_ID, - [{ tag: "BulletInAllowance", value: undefined }], - "Increase", - ); + 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); }); }); diff --git a/src/utils/allowances/bulletin.ts b/src/utils/allowances/bulletin.ts index 4aaa66a..3ede625 100644 --- a/src/utils/allowances/bulletin.ts +++ b/src/utils/allowances/bulletin.ts @@ -17,29 +17,20 @@ 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 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"; 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): string { return `Open the Bulletin authorization faucet at ${BULLETIN_AUTHORIZATION_URL} and authorize account ${slotAccountAddress}, then re-run \`dot init\`.`; } @@ -71,119 +62,31 @@ async function getBulletinSlotAuthorization( return await checkAuthorization(bulletinApi, getSlotAccountAddress(slotAccountKey)); } -export async function waitForBulletinSlotAuthorization( - 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)); - } - - 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. ${bulletinAuthorizationHelp(address)}`, - ); -} - 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); - const status = await getBulletinSlotAuthorization(bulletinApi, cached); - if (hasUsableAuthorization(status, requiredBytes)) { - return createSlotAccountSigner(cached); - } - if (!publishSigner.userSession) { - throw new Error( - `Cached Bulletin allowance key is not authorized. ${bulletinAuthorizationHelp(getSlotAccountAddress(cached))}`, - ); - } - return await requestAndStoreBulletinAllowanceSigner({ - env, - ownerAddress, - productId, - publishSigner, - bulletinApi, - requiredBytes, - policy: status.authorized ? "Increase" : "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"); - await storeSlotAccountKey(env, ownerAddress, "BulletInAllowance", key); + 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)}`, + ); } - await markAllowance(env, ownerAddress, "BulletInAllowance", "host"); 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 00db4fd..cb455fb 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, @@ -100,4 +101,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 e821046..43c9a7e 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/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)); }