Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bulletin-allowance-resilience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"playground-cli": patch
---

Make `dot init` survive Bulletin allowance propagation lag, and fix a React setState warning that landed in the previous account-derivation PR.

- **`dot init` no longer aborts** when the RFC-0010 Bulletin slot account is returned by mobile but the on-chain authorization hasn't propagated to Bulletin Chain yet. The slot key + marker are persisted regardless (so the next `dot deploy` picks them up), and the funding/mapping step continues to run. The row shows a soft-failure warning with the slot account SS58 and a faucet URL.
- New `BULLETIN_AUTHORIZATION_URL` + `bulletinAuthorizationHelp(slotAddress)` so timeout / cached-key-not-authorized errors point at `https://paritytech.github.io/polkadot-bulletin-chain/authorizations` with the exact slot SS58 to authorize manually.
- `requestAndStoreBulletinAllowanceSigner` persists the slot key before waiting for chain confirmation. A propagation timeout no longer discards a valid key the mobile already derived.
- `storeSlotAccountKeysFromOutcomes` is now a single read-modify-write so two slot keys returned in one call (e.g. BulletInAllowance + StatementStoreAllowance) can't race-clobber each other in `allowance-keys.json`.
- Fix a "Cannot update a component while rendering a different component" warning from `QrLogin`: it was calling the parent's `onDone(setState)` from inside `setStatus(updater)`. The handler now captures the resolved addresses in a `useRef` and calls `onDone` after the promise resolves, outside any updater function.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ These aren't self-evident from reading the code and have bitten us before. Treat
- **`getSessionSigner()` returns an adapter that keeps the Node event loop alive.** Every caller must invoke the returned `destroy()` when done. Forgetting it manifests as `dot <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`.
- **`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
72 changes: 50 additions & 22 deletions src/commands/init/AccountSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,21 @@ export function AccountSetup({
// Allowances are requested against the product-derived account
// (host-papp's `productAccountId = [PLAYGROUND_PRODUCT_ID, 0]`),
// which is the same SS58 used everywhere else in this flow.
//
// A note on non-fatal Bulletin timeouts: mobile derives the slot
// account from the user's root and submits `claim_long_term_storage`
// on People Chain; the authorization is supposed to propagate to
// Bulletin Chain via on-chain mechanics. The mobile waits 30s for
// visibility and swallows the failure, returning the slot key
// regardless. Our wait can therefore time out even on the happy
// path where the chain *will* catch up. The slot key + marker are
// still cached so the next run / `dot deploy` picks them up, and
// the funding + mapping steps below DO NOT depend on Bulletin
// authorization — they only need the product account on Asset
// Hub. Treat the Bulletin timeout as a soft failure, surface the
// faucet help, and continue.
update(0, { status: "active", value: "checking…", valueTone: "muted" });
let allowancesOk = false;
let accountSetupOk = true;
try {
const tags = PLAYGROUND_RESOURCES.map((r) => r.tag);
const marked = await Promise.all(tags.map((t) => hasAllowance(env, address, t)));
Expand Down Expand Up @@ -178,7 +191,6 @@ export function AccountSetup({
value: "already granted",
valueTone: "muted",
});
allowancesOk = true;
} else {
update(0, {
status: "active",
Expand All @@ -197,16 +209,17 @@ export function AccountSetup({
if (cancelled) return;
setPhonePrompt(null);
const summary = summarizeOutcomes(outcomes, PLAYGROUND_RESOURCES);
const bulletinKey = extractSlotAccountKey(outcomes, "BulletInAllowance");
if (bulletinKey) {
await waitForBulletinSlotAuthorization(client.bulletin, bulletinKey);
}

// Persist every slot key the mobile returned BEFORE the
// Bulletin propagation wait — a `waitForBulletinSlotAuthorization`
// timeout below shouldn't discard a perfectly valid key.
await storeSlotAccountKeysFromOutcomes(env, address, outcomes);
// RFC-0010 allocation outcomes are independent: keep any
// successful keys even if a sibling resource was denied.
await Promise.all(
summary.granted.map((r) => markAllowance(env, address, r.tag, "host")),
);

if (summary.rejected.length > 0 || summary.unavailable.length > 0) {
const denied = [...summary.rejected, ...summary.unavailable]
.map(describeResource)
Expand All @@ -219,13 +232,37 @@ export function AccountSetup({
finish(false);
return;
}

const bulletinKey = extractSlotAccountKey(outcomes, "BulletInAllowance");
if (bulletinKey) {
try {
await waitForBulletinSlotAuthorization(client.bulletin, bulletinKey);
} catch (waitErr) {
// Soft failure: key + marker are cached above, so
// the next run / `dot deploy` will see them. The
// funding/mapping step doesn't need this, so we
// surface the help and keep going. The user has
// already approved on their phone at this point
// — the problem is People→Bulletin propagation,
// not a pending mobile prompt, so the row label
// mustn't ask them to re-approve.
accountSetupOk = false;
update(0, {
status: "failed",
value: "Bulletin authorization pending",
error: describe(waitErr),
valueTone: "warning",
});
}
}
if (cancelled) return;
update(0, {
status: "ok",
value: `granted (${summary.granted.length})`,
valueTone: "muted",
});
allowancesOk = true;
if (accountSetupOk) {
update(0, {
status: "ok",
value: `granted (${summary.granted.length})`,
valueTone: "muted",
});
}
}
} catch (err) {
setPhonePrompt(null);
Expand Down Expand Up @@ -256,15 +293,6 @@ export function AccountSetup({
// covers the cold-start case the deploy preflight error message
// ("Account is not mapped in Revive. Run `dot init`...") would
// otherwise leave the user stuck on.
if (!allowancesOk) {
update(1, {
status: "skipped",
value: "skipped — allowances missing",
valueTone: "muted",
});
finish(false);
return;
}
update(1, { status: "active", value: "checking balance…", valueTone: "muted" });
try {
const result = await topUpFromBulletinDev(client, address);
Expand Down Expand Up @@ -299,7 +327,7 @@ export function AccountSetup({
return;
}

finish(true);
finish(accountSetupOk);
})();

// Cleanup is the SOLE owner of `session?.destroy()`. Calling destroy()
Expand Down
26 changes: 15 additions & 11 deletions src/commands/init/QrLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { Row } from "../../utils/ui/theme/index.js";
import {
waitForLogin,
Expand All @@ -30,18 +30,22 @@ export function QrLogin({
onDone: (addresses: SessionAddresses | null) => void;
}) {
const [status, setStatus] = useState<LoginStatus>({ step: "waiting" });
// Snapshot the SessionAddresses from the success status update so the
// `.then` handler below can hand them to the parent without reading
// QrLogin's React state. Calling `onDone` inside a `setStatus(updater)`
// would invoke the parent's `setAddresses` from within React's render
// phase — that's the "Cannot update a component while rendering a
// different component" warning we shipped accidentally in #188.
const addressesRef = useRef<SessionAddresses | null>(null);

useEffect(() => {
// `waitForLogin` resolves with the product-account SS58 string for
// back-compat, but the full `SessionAddresses` bundle only lives on
// the most-recent "success" status update. Snapshot it via
// `setStatus` so we hand the parent the whole triple, not just the
// SS58 — the parent needs `rootAddress` for the username lookup.
waitForLogin(login, setStatus).then(() => {
setStatus((current) => {
onDone(current.step === "success" ? current.addresses : null);
return current;
});
waitForLogin(login, (next) => {
setStatus(next);
if (next.step === "success") {
addressesRef.current = next.addresses;
}
}).then(() => {
onDone(addressesRef.current);
});
}, []);

Expand Down
9 changes: 9 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ export interface ChainConfig {
bulletinAuthorizeV2: boolean;
/** Public faucet URL, or null when allowances replace the funder flow. */
faucetUrl: string | null;
/**
* Web faucet URL for manually authorizing a Bulletin slot account when
* RFC-0010 People→Bulletin propagation lags. Surfaced in
* `bulletinAuthorizationHelp` so the user has a recovery path on
* testnets. `null` on production / closed-devnet envs where allowances
* are pre-allocated and no manual path exists.
*/
bulletinAuthorizationUrl: string | null;
}

// Paseo Next v2 — the active env. DotNS contracts are owned by
Expand All @@ -84,6 +92,7 @@ const PASEO_NEXT_V2: ChainConfig = {
autoAccountMapping: true,
bulletinAuthorizeV2: true,
faucetUrl: null,
bulletinAuthorizationUrl: "https://paritytech.github.io/polkadot-bulletin-chain/authorizations",
};

const CONFIGS: Partial<Record<Env, ChainConfig>> = {
Expand Down
38 changes: 37 additions & 1 deletion src/utils/allowances/bulletin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ vi.mock("@parity/product-sdk-bulletin", () => ({
checkAuthorization: checkAuthorizationMock,
}));

import { hasUsableBulletinSlotAuthorization } from "./bulletin.js";
import { getChainConfig } from "../../config.js";
import { bulletinAuthorizationHelp, hasUsableBulletinSlotAuthorization } from "./bulletin.js";

const KEY = secretFromSeed(new Uint8Array(32).fill(7));

Expand Down Expand Up @@ -66,3 +67,38 @@ describe("Bulletin allowance authorization", () => {
await expect(hasUsableBulletinSlotAuthorization({} as any, KEY, 50)).resolves.toBe(false);
});
});

describe("bulletinAuthorizationHelp", () => {
const ADDR = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY";

it("includes the explicitly-passed faucet URL + slot account SS58", () => {
const help = bulletinAuthorizationHelp(ADDR, "https://example.test/faucet");
expect(help).toContain("https://example.test/faucet");
expect(help).toContain(ADDR);
// The user needs an actionable instruction, not just a URL drop —
// make sure the "re-run dot init" hint stays in the string.
expect(help).toMatch(/re-run.*dot init/i);
});

it("falls back to a propagation-pending message when no faucet URL is configured", () => {
const help = bulletinAuthorizationHelp(ADDR, null);
// Mainnet / closed Summit devnet won't have a public faucet — the
// help must not invite users to a URL that doesn't apply.
expect(help).not.toMatch(/https?:\/\//);
expect(help).toContain(ADDR);
expect(help).toMatch(/propagation/i);
});

it("defaults to the active env's bulletinAuthorizationUrl when no URL passed", () => {
const help = bulletinAuthorizationHelp(ADDR);
const cfgUrl = getChainConfig().bulletinAuthorizationUrl;
if (cfgUrl) {
expect(help).toContain(cfgUrl);
// Pin the literal path component so a future rename of the
// constant can't silently produce a wrong user-facing URL.
expect(help).toMatch(/paritytech\.github\.io\/polkadot-bulletin-chain\/authorizations/);
} else {
expect(help).not.toMatch(/https?:\/\//);
}
});
});
52 changes: 46 additions & 6 deletions src/utils/allowances/bulletin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import type { PolkadotSigner } from "polkadot-api";
import { checkAuthorization, type BulletinApi } from "@parity/product-sdk-bulletin";
import type { Env } from "../../config.js";
import { getChainConfig, type Env } from "../../config.js";
import type { ResolvedSigner } from "../signer.js";
import { requestResourceAllocation, type OnExistingAllowancePolicy } from "./host.js";
import { markAllowance } from "./marker.js";
Expand All @@ -27,6 +27,32 @@ import {
storeSlotAccountKey,
} from "./slotKeys.js";

/**
* Help string appended to every Bulletin-allowance error that comes from
* the chain-side authorization not being visible on Bulletin yet. The
* mobile may have submitted `Resources::claim_long_term_storage` on
* People successfully but the People→Bulletin propagation hasn't landed
* (or the chain rejected it silently with `LongTermStorageAllocationFailed`,
* which mobile's `.logFailure(...)` swallows). Surfacing the slot SS58 +
* the env's faucet URL (when one is configured) gives the user a concrete
* recovery path on testnets. On envs without a faucet (mainnet / closed
* Summit devnet — `bulletinAuthorizationUrl: null`) we fall back to a
* generic "propagation pending" message rather than pointing at a URL
* that doesn't apply.
*
* `faucetUrl` defaults to the active env's config so callers don't have
* to plumb it through; pass `null` explicitly to render the no-faucet
* variant, or override in tests for determinism.
*/
export function bulletinAuthorizationHelp(
slotAccountAddress: string,
faucetUrl: string | null = getChainConfig().bulletinAuthorizationUrl,
): string {
return faucetUrl
? `Open the Bulletin authorization faucet at ${faucetUrl} and authorize account ${slotAccountAddress}, then re-run \`dot init\`.`
: `Bulletin allowance for ${slotAccountAddress} is not authorized on chain yet — wait a moment for People→Bulletin propagation, then re-run \`dot init\`.`;
}

export interface BulletinAllowanceSignerOptions {
env: Env;
ownerAddress: string;
Expand Down Expand Up @@ -77,10 +103,11 @@ export async function waitForBulletinSlotAuthorization(
await new Promise((resolve) => setTimeout(resolve, BULLETIN_AUTH_POLL_MS));
}

const help = bulletinAuthorizationHelp(address);
throw new Error(
lastAuthorized
? `Bulletin allowance for ${address} is live but does not have enough quota.`
: `Mobile returned Bulletin allowance key ${address}, but it is not authorized on Bulletin yet.`,
? `Bulletin allowance for ${address} is live but does not have enough quota. ${help}`
: `Mobile returned Bulletin allowance key ${address}, but it is not authorized on Bulletin yet. ${help}`,
);
}

Expand All @@ -104,7 +131,12 @@ export async function getBulletinAllowanceSigner({
return createSlotAccountSigner(cached);
}
if (!publishSigner.userSession) {
throw new Error("Cached Bulletin allowance key is not authorized. Run `dot init`.");
const slotAddress = getSlotAccountAddress(cached);
throw new Error(
`Cached Bulletin allowance key ${slotAddress} is not authorized. ${bulletinAuthorizationHelp(
slotAddress,
)}`,
);
}
return await requestAndStoreBulletinAllowanceSigner({
env,
Expand Down Expand Up @@ -164,12 +196,20 @@ export async function requestAndStoreBulletinAllowanceSigner({
throw new Error(`Bulletin allowance was not granted (${outcome}).`);
}

// Persist the key BEFORE the propagation wait. If the wait throws
// (chain hasn't reflected the People-side claim yet), we still want
// the next `dot init` / `dot deploy` to find the cached key instead
// of forcing the user to re-pair from scratch. The mobile derived
// it from the user's root via the deterministic
// `//allowance//bulletin//<productId>` path, so the same key will
// be valid the moment the chain catches up.
await storeSlotAccountKey(env, ownerAddress, "BulletInAllowance", key);
await markAllowance(env, ownerAddress, "BulletInAllowance", "host");

if (bulletinApi) {
await waitForBulletinSlotAuthorization(bulletinApi, key, requiredBytes);
}

await storeSlotAccountKey(env, ownerAddress, "BulletInAllowance", key);
await markAllowance(env, ownerAddress, "BulletInAllowance", "host");
return createSlotAccountSigner(key);
}

Expand Down
Loading
Loading