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
5 changes: 5 additions & 0 deletions .changeset/dev-signer-no-phone-taps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"playground-cli": patch
---

Dev-signer deploys no longer ask for phone approvals. bulletin-deploy 0.8.x resolves the persisted `playground init` login session whenever it is called without explicit auth options, which silently routed dev-mode DotNS signing through the phone and signed storage chunks with the user's phone-granted Bulletin quota. `--signer dev` now pins bulletin-deploy to its dev mnemonic and dev storage key explicitly, restoring zero-tap dev deploys. `--suri` deploys likewise pin chunk-upload signing to the suri key instead of silently using the cached slot key. Apps still appear in the owner's MyApps view when a session exists, and dev deploys still earn no XP.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ These aren't self-evident from reading the code and have bitten us before. Treat
- **Bulletin storage chunks must NEVER sign with the phone session signer.** Chunk txs carry up to 2 MiB of callData; the phone path (`session.createTransaction`) forwards the full callData over the statement store, whose request cap is 4 KiB on the pinned host-papp 0.7.9 (254 KiB upstream; Android itself caps statements at 256 KiB), so every chunk dies client-side with "Mobile transaction signing rejected: message too big" and the phone never even shows a prompt. Since bulletin-deploy 0.8.x, passing `signer` routes STORAGE through it too (not just DotNS), so phone mode must also pass `storageSigner`/`storageSignerAddress` (the local BulletInAllowance slot key, which takes precedence for storage routing only). `src/utils/deploy/signerMode.ts::resolveStorageSignerOptions` is the single place that resolves it; both `runDeploy` and `runDecentralize` thread it into `runStorageDeploy`. bulletin-deploy 0.8.3 can auto-resolve the same slot key from the shared `dot-cli` allowance cache, but silently falls back to phone-signing the chunks when it misses, so don't rely on it.
- **Deploy delegates to `bulletin-deploy` for everything storage-related** — chunking, retries, pool accounts, nonce fallback, DAG-PB, DotNS commit-reveal. Don't reimplement. The one thing we own is `registry.publish()`. The contract takes an `Option<Address> owner` parameter — when None, it falls back to `env::caller()`; when Some, that H160 is recorded as the app owner regardless of who signed. Phone mode passes None (caller IS the user). Dev mode with an active session passes the session's `productH160` so Alice can sign the tx while the user still appears in MyApps. The `publisher` field on `AppInfo` always stores `env::caller()`, so `is_authorized_to_republish` lets the original signer iterate without rewriting ownership. See `src/utils/deploy/playground.ts` and `src/utils/deploy/signerMode.ts::resolveSignerSetup`.
- **Do NOT call `bulletin-deploy.deploy()` just to store a metadata JSON.** `deploy()` unconditionally runs a DotNS `register()` + `setContenthash()`, and for `domainName: null` invents a `test-domain-<random>` label and registers THAT — the side-trip reverts cryptically. For metadata storage we submit `TransactionStorage.store` directly via PAPI using `calculateCid` from `@parity/product-sdk-bulletin`. The metadata `store` is signed with the product-scoped RFC-0010 Bulletin allowance account cached in `allowance-keys.json` (not Alice, not the product account). Asset Hub `registry.publish` is signed with the user's product account in phone mode, and with a dev signer in dev mode (claimed-owner H160 carries the user identity, per the bullet above). See `src/utils/deploy/playground.ts::publishToPlayground`.
- **Dev mode must pass EXPLICIT auth options to `bulletin-deploy.deploy()` — never `{}`.** Since 0.8.x (the "#411 login UX"), `deploy()` called with no `mnemonic`, no `signer`, and no `suri` probes for a persisted SSO session file (`~/.polkadot-apps/dot-cli_SsoSessions.json` — the SAME namespace `playground init` writes, because bulletin-deploy reuses `DOT_DAPP_ID = "dot-cli"`) and, when found, loads the SSO stack and phone-signs DotNS with the user's session — turning a "0 taps" dev deploy into 3-4 phone approvals for every logged-in user. Independently, an absent `storageSigner` makes it auto-read the user's cached BulletInAllowance slot key and burn their small phone-granted quota on chunk uploads, in every mode including `--suri`. `resolveSignerSetup` therefore pins `mnemonic: DEFAULT_MNEMONIC` for dev mode and `resolveStorageSignerOptions` pins `storageSigner` to the dev bare-root (dev) or the `--suri` key (suri) — the bare-root carries its own Bulletin authorization on paseo-next-v2, and bulletin-deploy's committed-signer wrapper falls back to the shared pool if it ever lapses. Tests in `signerMode.test.ts`, `run.test.ts`, and `decentralize/run.test.ts` pin the contract.
- **The "dev signer" used in dev mode is bulletin-deploy's `DEFAULT_MNEMONIC` bare-root account, not Substrate's `//Alice`.** The bare-root SS58 (`5DfhGyQd…`) is what bulletin-deploy uses internally for its DEFAULT_MNEMONIC storage + DotNS signing, so the CLI's `createDevPublishSigner` derives from the same `(mnemonic, path="")` pair via `seedToAccount`. Storage, DotNS, and registry publish all sign as one identity. Substrate's `//Alice` (`5Grwva…`) is a DIFFERENT account — `createDevSigner("Alice")` from `@parity/product-sdk-tx` returns that one. Don't mix them; the `signerModeAlice.test.ts` snapshot guards against regression.
- **Dev-mode re-publish only works on apps that were first published from dev mode.** `is_authorized_to_republish` accepts `caller == owner OR caller == publisher`. In dev mode the publisher is always Alice (`5DfhGyQd…`), so dev-mode re-deploys of a dev-published app succeed. But an app first published from phone mode has `caller == publisher == user H160`; Alice is neither, so a dev-mode re-deploy reverts `Unauthorized`. To iterate on a phone-published app in dev mode the user must unpublish it from phone mode first. Intentional asymmetry: once a user "owns" an app from their phone, a shared dev key can't touch it.
- **Build a dedicated Bulletin client with `heartbeatTimeout: 300_000` for the metadata upload.** The shared client from `getConnection()` uses `@parity/product-sdk-chain-client`'s default 40 s heartbeat; a single `TransactionStorage.store` round-trip can exceed that and the socket tears down as `WS halt (3)`. We mirror bulletin-deploy's 300 s heartbeat with a one-off client that gets destroyed immediately after the upload.
Expand Down
13 changes: 10 additions & 3 deletions src/utils/decentralize/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ vi.mock("../allowances/slotSigner.js", () => ({
readCachedBulletinSlotSigner: vi.fn(async () => null),
}));

import { DEFAULT_MNEMONIC } from "bulletin-deploy";
import type { ResolvedSigner } from "../signer.js";
import { DEV_PUBLISH_ADDRESS } from "../deploy/signerMode.js";
import { describeDeployEvent, runDecentralize } from "./run.js";

describe("describeDeployEvent", () => {
Expand Down Expand Up @@ -137,7 +139,7 @@ describe("runDecentralize — Bulletin storage signer", () => {
expect(arg.auth.storageSignerAddress).not.toBe("5Fake");
});

it("dev mode passes no storageSigner and never touches the slot key", async () => {
it("dev mode pins the dev mnemonic + dev storage signer and never touches the slot key", async () => {
await runDecentralize({
siteUrl: "https://example.com",
label: "my-site",
Expand All @@ -148,9 +150,14 @@ describe("runDecentralize — Bulletin storage signer", () => {
});

const arg = runStorageDeployMock.mock.calls[0][0] as unknown as {
auth: { storageSigner?: unknown };
auth: { mnemonic?: string; signer?: unknown; storageSignerAddress?: string };
};
expect(arg.auth.storageSigner).toBeUndefined();
// Explicit dev identity: an empty auth object would let bulletin-deploy
// 0.8.x resolve the persisted phone session (DotNS taps) and the
// user's cached slot key (quota burn). See signerMode.ts.
expect(arg.auth.mnemonic).toBe(DEFAULT_MNEMONIC);
expect(arg.auth.signer).toBeUndefined();
expect(arg.auth.storageSignerAddress).toBe(DEV_PUBLISH_ADDRESS);
expect(ensureSlotAccountSignerMock).not.toHaveBeenCalled();
});
});
16 changes: 10 additions & 6 deletions src/utils/decentralize/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { publishToPlayground } from "../deploy/playground.js";
import type { DeployLogEvent } from "../deploy/progress.js";
import {
type DeployApproval,
DEV_PUBLISH_ADDRESS,
resolveSignerSetup,
resolveStorageSignerOptions,
type SignerMode,
Expand Down Expand Up @@ -135,12 +136,15 @@ export async function runDecentralize(
publishToPlayground: wantPlayground,
});

// Pick the signer used for the DotNS register tx. bulletin-deploy
// accepts `{ signer, signerAddress }` or `{}` (falls back to its
// DEFAULT_MNEMONIC). Either way we surface a single visible address
// for the outcome.
// Pick the signer used for the DotNS register tx. bulletin-deploy gets
// `{ signer, signerAddress }` (phone / `--suri`) or `{ mnemonic }` (dev —
// always explicit, never `{}`: empty options make 0.8.x resolve the
// persisted phone session). Either way we surface a single visible
// address for the outcome; the dev mnemonic's bare root is
// `DEV_PUBLISH_ADDRESS`.
const storageSignerAddress =
setup.bulletinDeployAuthOptions.signerAddress ??
(setup.bulletinDeployAuthOptions.mnemonic ? DEV_PUBLISH_ADDRESS : null) ??
setup.publishSigner?.address ??
// Defensive fallback: should never hit because dev mode synthesises
// a signer for the publish phase even when one isn't strictly
Expand Down Expand Up @@ -199,7 +203,7 @@ export async function runDecentralize(
domainName: label,
// Wrap the DotNS auth signer so each phone tap surfaces a
// "check your phone" lifecycle event. No-op in dev mode (auth
// has no signer — bulletin-deploy uses its default mnemonic).
// carries a mnemonic, not a signer — signed in-process).
auth: {
...wrapAuthForSigning(
setup.bulletinDeployAuthOptions,
Expand Down Expand Up @@ -284,7 +288,7 @@ export async function runDecentralize(
* Wrap the bulletin-deploy DotNS auth signer so each `signTx` call surfaces a
* "check your phone" lifecycle event labelled by the matching DotNS approval.
* Mirrors deploy's `maybeWrapAuthForSigning`. Returns `auth` unchanged when
* there's no signer (dev mode → bulletin-deploy uses its default mnemonic,
* there's no signer (dev mode → explicit dev mnemonic signed in-process,
* no human tap).
*/
function wrapAuthForSigning(
Expand Down
10 changes: 8 additions & 2 deletions src/utils/deploy/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ vi.mock("./storageQuota.js", () => ({
}));
const quotaApi = { marker: "bulletin-api" } as any;

import { DEFAULT_MNEMONIC } from "bulletin-deploy";
import { runDeploy, type DeployEvent } from "./run.js";
import { DEV_PUBLISH_ADDRESS } from "./signerMode.js";
import type { ResolvedSigner } from "../signer.js";

const fakeUserSigner: ResolvedSigner = {
Expand Down Expand Up @@ -165,10 +167,14 @@ describe("runDeploy", () => {
const plan = events.find((e) => e.kind === "plan");
expect(plan).toEqual({ kind: "plan", approvals: [] });

// bulletin-deploy auth must be empty in dev mode.
// bulletin-deploy auth must pin the dev identity explicitly: an
// empty object makes 0.8.x resolve the persisted phone session for
// DotNS and the user's cached slot key for storage (see signerMode.ts).
expect(runStorageDeploy).toHaveBeenCalledTimes(1);
const arg = runStorageDeploy.mock.calls[0][0];
expect(arg.auth).toEqual({});
expect(arg.auth.mnemonic).toBe(DEFAULT_MNEMONIC);
expect(arg.auth.signer).toBeUndefined();
expect(arg.auth.storageSignerAddress).toBe(DEV_PUBLISH_ADDRESS);
expect(arg.domainName).toBe("my-app");

// Dev mode never opens a Bulletin client for quota checks — no slot
Expand Down
65 changes: 56 additions & 9 deletions src/utils/deploy/signerMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,13 @@ vi.mock("../allowances/bulletin.js", () => ({
getBulletinAllowanceSigner: getBulletinAllowanceSignerMock,
}));

import { DEFAULT_MNEMONIC } from "bulletin-deploy";
import { ss58Encode } from "@parity/product-sdk-address";
import { resolveSignerSetup, resolveStorageSignerOptions } from "./signerMode.js";
import {
resolveSignerSetup,
resolveStorageSignerOptions,
DEV_PUBLISH_ADDRESS,
} from "./signerMode.js";
import type { ResolvedSigner } from "../signer.js";

function fakeSigner(
Expand All @@ -53,17 +58,37 @@ function fakeSigner(
}

describe("resolveSignerSetup — dev mode", () => {
it("no publish, no funding → empty approvals, empty auth options, null publishSigner", () => {
it("no publish, no funding → empty approvals, explicit dev mnemonic, null publishSigner", () => {
const result = resolveSignerSetup({
mode: "dev",
userSigner: null,
publishToPlayground: false,
});
expect(result.approvals).toEqual([]);
expect(result.bulletinDeployAuthOptions).toEqual({});
expect(result.bulletinDeployAuthOptions).toEqual({ mnemonic: DEFAULT_MNEMONIC });
expect(result.publishSigner).toBeNull();
});

it("pins the DEFAULT_MNEMONIC explicitly so bulletin-deploy can never pick up a persisted phone session", () => {
// Regression: bulletin-deploy 0.8.x resolves the persisted SSO session
// (~/.polkadot-apps/dot-cli_SsoSessions.json — written by `playground
// init`, shared namespace) whenever it is called with NO mnemonic, NO
// signer, and NO suri. Passing `{}` therefore turned dev mode into
// phone mode (DotNS taps on the phone) for every logged-in user.
// An explicit mnemonic short-circuits its chooseSignerInput before
// the session probe.
const result = resolveSignerSetup({
mode: "dev",
userSigner: fakeSigner("session", "5User"),
publishToPlayground: false,
});
expect(result.bulletinDeployAuthOptions.mnemonic).toBe(DEFAULT_MNEMONIC);
// No signer key: run.ts's maybeWrapAuthForSigning must not wrap dev
// deploys in the phone-approval event proxy.
expect(result.bulletinDeployAuthOptions.signer).toBeUndefined();
expect(result.bulletinDeployAuthOptions.signerAddress).toBeUndefined();
});

it("publishToPlayground with active session signs as Alice but claims session H160 as owner — zero phone taps", () => {
const user = fakeSigner("session", "5User", "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
const result = resolveSignerSetup({
Expand All @@ -79,8 +104,9 @@ describe("resolveSignerSetup — dev mode", () => {
// The user's H160 is claimed via the owner parameter so MyApps still
// resolves their app even though Alice signed the tx.
expect(result.claimedOwnerH160).toBe("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
// Dev mode keeps bulletin-deploy on its built-in default mnemonic.
expect(result.bulletinDeployAuthOptions).toEqual({});
// Dev mode keeps bulletin-deploy on its built-in default mnemonic —
// passed EXPLICITLY so the persisted phone session is never resolved.
expect(result.bulletinDeployAuthOptions).toEqual({ mnemonic: DEFAULT_MNEMONIC });
});

it("publishToPlayground without any signer falls back to pure Alice ownership", () => {
Expand Down Expand Up @@ -111,6 +137,8 @@ describe("resolveSignerSetup — dev mode", () => {
expect(result.claimedOwnerH160).toBeNull();
expect(result.bulletinDeployAuthOptions.signer).toBe(user.signer);
expect(result.bulletinDeployAuthOptions.signerAddress).toBe("5DevSuri");
// The injected signer wins inside bulletin-deploy; no mnemonic needed.
expect(result.bulletinDeployAuthOptions.mnemonic).toBeUndefined();
});
});

Expand Down Expand Up @@ -245,14 +273,33 @@ describe("resolveStorageSignerOptions", () => {
});
});

it("dev mode never touches the slot key — no phone prompt in dev mode", async () => {
it("dev mode pins storage to the dev publish account — never the user's slot key, no phone prompt", async () => {
// Regression: bulletin-deploy 0.8.x auto-reads the user's cached
// BulletInAllowance slot key whenever `storageSigner` is absent and
// signs chunk uploads with it — silently burning the user's small
// phone-granted quota on dev deploys. Pinning the dev bare-root
// (authorized on paseo-next-v2; pool fallback if it ever lapses)
// keeps dev deploys fully off the user's session resources.
const user = sessionSignerWithHost();
await expect(resolveStorageSignerOptions("dev", user)).resolves.toEqual({});
const result = await resolveStorageSignerOptions("dev", user);
expect(result.storageSigner).toBeDefined();
expect(result.storageSignerAddress).toBe(DEV_PUBLISH_ADDRESS);
expect(getBulletinAllowanceSignerMock).not.toHaveBeenCalled();
});

it("phone mode with a --suri dev signer returns {} (local key, no size hazard)", async () => {
await expect(resolveStorageSignerOptions("phone", fakeSigner("dev"))).resolves.toEqual({});
it("dev mode with a --suri signer pins storage to that key (caller owns its allowance)", async () => {
const user = fakeSigner("dev", "5DevSuri");
const result = await resolveStorageSignerOptions("dev", user);
expect(result.storageSigner).toBe(user.signer);
expect(result.storageSignerAddress).toBe("5DevSuri");
expect(getBulletinAllowanceSignerMock).not.toHaveBeenCalled();
});

it("phone mode with a --suri dev signer pins storage to that key (local key, no size hazard, no slot hijack)", async () => {
const user = fakeSigner("dev", "5DevSuri");
const result = await resolveStorageSignerOptions("phone", user);
expect(result.storageSigner).toBe(user.signer);
expect(result.storageSignerAddress).toBe("5DevSuri");
expect(getBulletinAllowanceSignerMock).not.toHaveBeenCalled();
});

Expand Down
Loading
Loading