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

Stop presenting an unanswered phone request as progress.

When the phone doesn't answer a signing request (usually because the Polkadot
app isn't open in the foreground, which shows no prompt at all), the deploy
re-sends it. Every re-send used to be numbered as the next step, so a user who
had approved nothing could see "step 3: Link content" on a deploy that needs
two approvals. For a new domain, a re-sent "Reserve domain" request was also
relabelled "Finalize domain".

Steps now count approvals rather than requests: a re-send keeps its step and
label and is shown as "No response yet — re-sent to your phone (attempt N)",
with a reminder to keep the app in the foreground. This applies to
`playground deploy`, `decentralize` and contract deploys, in both the
interactive screen and `--yes` output.
1 change: 1 addition & 0 deletions src/commands/contractDeployUi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ export function ContractDeployStatusView({
<PhoneApprovalCallout
step={adapter.signingPrompt.step}
label={adapter.signingPrompt.label}
attempt={adapter.signingPrompt.attempt}
/>
)}
{adapter.signingError && (
Expand Down
1 change: 1 addition & 0 deletions src/commands/contractPipelineStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ describe("ContractPipelineStatusAdapter", () => {
kind: "sign-request",
label: "Deploy and register contracts",
step: 1,
attempt: 1,
});
expect(adapter.signingPrompt?.label).toBe("Deploy and register contracts");

Expand Down
6 changes: 5 additions & 1 deletion src/commands/decentralize/DecentralizeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -894,7 +894,11 @@ function RunningStage({
</Section>

{signingPrompt && signingPrompt.kind === "sign-request" && (
<PhoneApprovalCallout step={signingPrompt.step} label={signingPrompt.label} />
<PhoneApprovalCallout
step={signingPrompt.step}
label={signingPrompt.label}
attempt={signingPrompt.attempt}
/>
)}
</Box>
);
Expand Down
5 changes: 2 additions & 3 deletions src/commands/decentralize/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
import type { SignerMode } from "../../utils/deploy/signerMode.js";
import { PLAYGROUND_TAGS } from "../../utils/deploy/tags.js";
import { onProcessShutdown } from "../../utils/process-guard.js";
import { formatSignRequestLine } from "../../utils/ui/theme/phoneApprovalCopy.js";

interface DecentralizeOpts {
site?: string;
Expand Down Expand Up @@ -302,9 +303,7 @@ async function runHeadless({
}
case "signing":
if (ev.event.kind === "sign-request") {
process.stdout.write(
`\n ▸ Check your phone — approve step ${ev.event.step}: ${ev.event.label}\n`,
);
process.stdout.write(`\n${formatSignRequestLine(ev.event)}`);
} else if (ev.event.kind === "sign-error") {
process.stdout.write(` ✖ signing failed: ${ev.event.message}\n`);
}
Expand Down
1 change: 1 addition & 0 deletions src/commands/deploy/DeployScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1223,6 +1223,7 @@ function RunningStage({
<PhoneApprovalCallout
step={signingPrompt.step}
label={signingPrompt.label}
attempt={signingPrompt.attempt}
/>
)}
</>
Expand Down
36 changes: 3 additions & 33 deletions src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { normalizeDomain } from "../../utils/deploy/playground.js";
import { assertBuildDirExists } from "../../utils/deploy/buildDir.js";
import { PLAYGROUND_TAGS } from "../../utils/deploy/tags.js";
import { NO_SESSION_HEADLESS_ERROR } from "./signerNotice.js";
import { formatSignRequestLine } from "../../utils/ui/theme/phoneApprovalCopy.js";

interface DeployOpts {
suri?: string;
Expand Down Expand Up @@ -750,37 +751,6 @@ function runInteractive(ctx: {

// ── Output helpers ───────────────────────────────────────────────────────────

/**
* Render a phone signature request, distinguishing a RETRY from progress.
*
* The `step` on the event counts signature REQUESTS, not operations: when a
* request times out (the usual cause is the phone app not being in the
* foreground, which shows no prompt at all) it is re-sent with the next number.
* Printing that number made a stall look like progress — worse, it contradicted
* the "Phone approvals expected" plan above it, which numbers OPERATIONS. Seen
* live: `step 1: Link content` followed by `step 2: Link content`, while the
* plan said step 2 would be the registry publish.
*
* So we key off the label instead and say plainly when we are re-sending, plus
* the one remedy that actually fixes it.
*/
const phoneSignAttempts = new Map<string, number>();

export function formatSignRequest(label: string): string {
const attempt = (phoneSignAttempts.get(label) ?? 0) + 1;
phoneSignAttempts.set(label, attempt);
if (attempt === 1) return ` 📱 Approve on your phone: ${label}\n`;
return (
` 📱 No response yet — re-sent to your phone (attempt ${attempt}): ${label}\n` +
` If no prompt appeared, open the Polkadot app and keep it in the foreground.\n`
);
}

/** Test seam: the attempt counter is module state, so tests must reset it. */
export function resetSignRequestAttempts(): void {
phoneSignAttempts.clear();
}

function logHeadlessEvent(event: DeployEvent) {
if (event.kind === "phase-start") {
process.stdout.write(`▸ ${event.phase}…\n`);
Expand All @@ -791,7 +761,7 @@ function logHeadlessEvent(event: DeployEvent) {
} else if (event.kind === "storage-event" && event.event.kind === "chunk-progress") {
process.stdout.write(` chunk ${event.event.current}/${event.event.total}\n`);
} else if (event.kind === "signing" && event.event.kind === "sign-request") {
process.stdout.write(formatSignRequest(event.event.label));
process.stdout.write(formatSignRequestLine(event.event));
} else if (event.kind === "error") {
process.stderr.write(` ✖ ${event.phase}: ${event.message}\n`);
}
Expand Down Expand Up @@ -823,7 +793,7 @@ function logHeadlessContractInstallEvent(event: ContractInstallEvent) {

function logHeadlessSigningEvent(event: SigningEvent) {
if (event.kind === "sign-request") {
process.stdout.write(` approve on your phone (step ${event.step}): ${event.label}\n`);
process.stdout.write(formatSignRequestLine(event));
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/commands/deploy/runningState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ describe("runningReducer — log/signing/plan events are no-ops", () => {
it("signing sign-request -> state unchanged", () => {
const s = runningReducer(s0, {
kind: "signing",
event: { kind: "sign-request", step: 1, label: "DotNS register" },
event: { kind: "sign-request", step: 1, attempt: 1, label: "DotNS register" },
});
expect(s).toBe(s0);
});
Expand Down
71 changes: 0 additions & 71 deletions src/commands/deploy/signRequest.test.ts

This file was deleted.

23 changes: 2 additions & 21 deletions src/utils/decentralize/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
createApprovalPrompt,
type SigningCounter,
type SigningEvent,
wrapDotnsSigner,
wrapSignerWithEvents,
} from "../deploy/signingProxy.js";
import { runStorageDeploy } from "../deploy/storage.js";
Expand Down Expand Up @@ -375,25 +376,5 @@ function wrapAuthForSigning(
if (!auth.signer || !auth.signerAddress) return auth;

const labels = approvals.filter((a) => a.phase === "dotns").map((a) => a.label);
const fallbackLabel = labels[labels.length - 1] ?? "DotNS step";
const signer = auth.signer;
let seen = 0;

return {
...auth,
signer: {
publicKey: signer.publicKey,
signTx: (...args: Parameters<typeof signer.signTx>) => {
const label = labels[seen] ?? fallbackLabel;
seen += 1;
return wrapSignerWithEvents(signer, { label, counter, onEvent }).signTx(...args);
},
signBytes: (...args: Parameters<typeof signer.signBytes>) =>
wrapSignerWithEvents(signer, {
label: "DotNS signBytes",
counter,
onEvent,
}).signBytes(...args),
},
};
return { ...auth, signer: wrapDotnsSigner(auth.signer, labels, counter, onEvent) };
}
44 changes: 10 additions & 34 deletions src/utils/deploy/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from "./signerMode.js";
import {
wrapSignerWithEvents,
wrapDotnsSigner,
createSigningCounter,
createApprovalPrompt,
type SigningCounter,
Expand Down Expand Up @@ -326,15 +327,11 @@ export async function runDeploy(options: RunDeployOptions): Promise<DeployOutcom
* When polkadot-app-deploy is about to use the user's phone signer for DotNS, wrap
* it so each `signTx` call surfaces a lifecycle event with the right label.
*
* Labels are pulled from the DotNS-phase entries of `setup.approvals`, in
* order. `resolveSignerSetup` built that list to match polkadot-app-deploy's
* actual on-chain call sequence (the DotNS commitment / register /
* setContenthash steps), so `seen === N` → phone shows
* the Nth entry. If polkadot-app-deploy ever fires *more* sigs than approvals
* anticipated, we fall back to the last known label — better than emitting
* a bogus index. The step counter itself is plan-independent (bare
* sequential numbers, no predicted total), so extra or skipped sigs can't
* desync the displayed count.
* Labels are the DotNS-phase entries of `setup.approvals`, in order —
* `resolveSignerSetup` built that list to match polkadot-app-deploy's actual
* on-chain call sequence. See `wrapDotnsSigner` for how retries keep their
* label. The step counter itself is plan-independent (bare sequential numbers,
* no predicted total), so extra or skipped sigs can't desync the displayed count.
*/
function maybeWrapAuthForSigning(
auth: ReturnType<typeof resolveSignerSetup>["bulletinDeployAuthOptions"],
Expand All @@ -345,31 +342,10 @@ function maybeWrapAuthForSigning(
if (!auth.signer || !auth.signerAddress) return auth;

const labels = approvals.filter((a) => a.phase === "dotns").map((a) => a.label);
const fallbackLabel = labels[labels.length - 1] ?? "DotNS step";
let seen = 0;
const wrapped = {
publicKey: auth.signer.publicKey,
signTx: (...args: Parameters<typeof auth.signer.signTx>) => {
const label = labels[seen] ?? fallbackLabel;
seen += 1;
const proxy = wrapSignerWithEvents(auth.signer!, {
label,
counter,
onEvent: (event) => options.onEvent({ kind: "signing", event }),
});
return proxy.signTx(...args);
},
signBytes: (...args: Parameters<typeof auth.signer.signBytes>) => {
const proxy = wrapSignerWithEvents(auth.signer!, {
label: "DotNS signBytes",
counter,
onEvent: (event) => options.onEvent({ kind: "signing", event }),
});
return proxy.signBytes(...args);
},
};

return { ...auth, signer: wrapped };
const signer = wrapDotnsSigner(auth.signer, labels, counter, (event) =>
options.onEvent({ kind: "signing", event }),
);
return { ...auth, signer };
}

function wrapResolvedSigner(
Expand Down
Loading
Loading