Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/fix-init-product-account-display.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cmd>` 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(<ss58>)]` 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
Expand Down
151 changes: 80 additions & 71 deletions src/commands/init/AccountSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,19 @@ 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,
summarizeOutcomes,
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";

Expand Down Expand Up @@ -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) {
Expand All @@ -97,6 +98,8 @@ export function AccountSetup({
{ label: "funding", status: "pending" },
]);
const [phonePrompt, setPhonePrompt] = useState<PhonePrompt | null>(null);
const [bulletinWarning, setBulletinWarning] = useState<BulletinWarning | null>(null);
const bulletinAuthorizationUrl = getChainConfig(DEFAULT_ENV).bulletinAuthorizationUrl;

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -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",
Expand All @@ -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 ──────────────────
Expand Down Expand Up @@ -368,6 +348,35 @@ export function AccountSetup({
</Text>
</Callout>
)}
{bulletinWarning && (
<Callout tone="warning" title="Bulletin authorization needed">
{bulletinAuthorizationUrl ? (
<>
<Text>
Open the Bulletin authorization faucet at{" "}
<Text bold>{bulletinAuthorizationUrl}</Text>
</Text>
<Text>
and authorize account{" "}
<Text bold>{bulletinWarning.slotAccountAddress}</Text>
{", then re-run "}
<Text bold>dot init</Text>.
</Text>
</>
) : (
<>
<Text>
Bulletin allowance account{" "}
<Text bold>{bulletinWarning.slotAccountAddress}</Text> is not
authorized yet.
</Text>
<Text>
Re-run <Text bold>dot init</Text> after authorizing it.
</Text>
</>
)}
</Callout>
)}
</Box>
);
}
7 changes: 3 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
87 changes: 82 additions & 5 deletions src/utils/allowances/bulletin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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,
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
Loading
Loading