Skip to content
Open
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
34 changes: 34 additions & 0 deletions src/utils/decentralize/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
import { runStorageDeploy } from "../deploy/storage.js";
import type { ResolvedSigner } from "../signer.js";
import { mirrorSite } from "./mirror.js";
import { getBulletinAllowanceSigner } from "../allowances/bulletin.js";
import { getSlotAccountAddress, readSlotAccountKey } from "../allowances/slotKeys.js";

export type DecentralizeLogEvent =
| { kind: "mirror-start"; url: string }
Expand Down Expand Up @@ -168,6 +170,38 @@ export async function runDecentralize(
directory: mirror.uploadRoot,
});

// Phone mode: attempt to resolve a BulletIn slot-account signer so
// chunk uploads use the user's own allowance instead of the shared
// pool. Falls back silently to pool if absent or resolution fails.
if (mode === "phone" && userSigner) {
try {
onEvent?.({
kind: "signing",
event: { label: "Approve Bulletin storage allowance" },
} as any);
const slotSigner = await getBulletinAllowanceSigner({
env,
ownerAddress: userSigner.address,
publishSigner: userSigner,
});
const slotKey = await readSlotAccountKey(
env,
userSigner.address,
"BulletInAllowance",
);
const slotSs58 = slotKey ? getSlotAccountAddress(slotKey) : undefined;
if (slotSs58) {
setup.bulletinDeployAuthOptions = {
...setup.bulletinDeployAuthOptions,
storageSigner: slotSigner,
storageSignerAddress: slotSs58,
};
}
} catch {
// Slot signer resolution failed — pool fallback handled by bulletin-deploy.
}
}

onEvent?.({ kind: "storage-start", fullDomain });
const result = await runStorageDeploy({
// Upload from the resolved index.html parent, NOT from
Expand Down
90 changes: 90 additions & 0 deletions src/utils/deploy/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ const {
detectBuildConfigMock,
loadDetectInputMock,
withSpanMock,
getBulletinAllowanceSignerMock,
readSlotAccountKeyMock,
getSlotAccountAddressMock,
} = vi.hoisted(() => ({
runStorageDeploy: vi.fn<
(arg: any) => Promise<{
Expand Down Expand Up @@ -57,9 +60,23 @@ const {
configFiles: new Set<string>(),
})),
withSpanMock: vi.fn(async (_op: string, _name: string, _attrs: any, fn: any) => fn()),
getBulletinAllowanceSignerMock: vi.fn(async () => ({
publicKey: new Uint8Array(32),
signTx: async () => new Uint8Array(64),
signBytes: async () => new Uint8Array(64),
})),
readSlotAccountKeyMock: vi.fn(async () => new Uint8Array(32).fill(0x42)),
getSlotAccountAddressMock: vi.fn(() => "5SlotAddress"),
}));

vi.mock("./storage.js", () => ({ runStorageDeploy }));
vi.mock("../allowances/bulletin.js", () => ({
getBulletinAllowanceSigner: getBulletinAllowanceSignerMock,
}));
vi.mock("../allowances/slotKeys.js", () => ({
readSlotAccountKey: readSlotAccountKeyMock,
getSlotAccountAddress: getSlotAccountAddressMock,
}));
vi.mock("./playground.js", () => ({
publishToPlayground: publishToPlaygroundMock,
normalizeDomain: (d: string) => {
Expand Down Expand Up @@ -111,6 +128,9 @@ beforeEach(() => {
publishToPlaygroundMock.mockClear();
runBuildMock.mockClear();
withSpanMock.mockClear();
getBulletinAllowanceSignerMock.mockClear();
readSlotAccountKeyMock.mockClear();
getSlotAccountAddressMock.mockClear();
});

describe("runDeploy", () => {
Expand Down Expand Up @@ -380,4 +400,74 @@ describe("runDeploy", () => {
expect(ops).toContain("cli.deploy.storage-dotns");
expect(ops).toContain("cli.deploy.playground");
});

it("phone mode: slot signer injected into storage auth when resolution succeeds", async () => {
// getBulletinAllowanceSigner succeeds → storageSigner + storageSignerAddress
// must appear in the auth object passed to runStorageDeploy.
const { events, push } = collectEvents();
await runDeploy({
projectDir: "/tmp/proj",
buildDir: "/tmp/proj/dist",
skipBuild: true,
domain: "my-app",
mode: "phone",
publishToPlayground: false,
userSigner: fakeUserSigner,
onEvent: push,
});

expect(getBulletinAllowanceSignerMock).toHaveBeenCalledTimes(1);
const arg = runStorageDeploy.mock.calls[0][0];
// The wrapped signer (from maybeWrapAuthForSigning) carries storageSignerAddress through.
expect(arg.auth.storageSignerAddress).toBe("5SlotAddress");
expect(arg.auth.storageSigner).toBeDefined();

// A signing event must be emitted BEFORE getBulletinAllowanceSigner is
// called so the TUI can show "check your phone" while the allocator runs.
const signingEvents = events.filter((e) => e.kind === "signing") as Array<{
kind: "signing";
event: any;
}>;
expect(
signingEvents.some((e) => e.event?.label === "Approve Bulletin storage allowance"),
).toBe(true);
});

it("dev mode: getBulletinAllowanceSigner is NOT called", async () => {
const { push } = collectEvents();
await runDeploy({
projectDir: "/tmp/proj",
buildDir: "/tmp/proj/dist",
skipBuild: true,
domain: "my-app",
mode: "dev",
publishToPlayground: false,
userSigner: null,
onEvent: push,
});

expect(getBulletinAllowanceSignerMock).not.toHaveBeenCalled();
});

it("phone mode: slot resolution failure does not abort deploy (pool fallback)", async () => {
getBulletinAllowanceSignerMock.mockRejectedValueOnce(new Error("allocation failed"));
const { push } = collectEvents();
// Should resolve normally — error is swallowed, pool is used.
await expect(
runDeploy({
projectDir: "/tmp/proj",
buildDir: "/tmp/proj/dist",
skipBuild: true,
domain: "my-app",
mode: "phone",
publishToPlayground: false,
userSigner: fakeUserSigner,
onEvent: push,
}),
).resolves.toBeDefined();

// auth must NOT contain storageSigner (slot resolution failed).
const arg = runStorageDeploy.mock.calls[0][0];
expect(arg.auth.storageSignerAddress).toBeUndefined();
});
});
40 changes: 39 additions & 1 deletion src/utils/deploy/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
import { runBuild, loadDetectInput, detectBuildConfig, type BuildConfig } from "../build/index.js";
import { publishToPlayground, normalizeDomain } from "./playground.js";
import { resolveSignerSetup, type SignerMode, type DeployApproval } from "./signerMode.js";
import {
getBulletinAllowanceSigner,
} from "../allowances/bulletin.js";
import { getSlotAccountAddress, readSlotAccountKey } from "../allowances/slotKeys.js";
import {
wrapSignerWithEvents,
createSigningCounter,
Expand All @@ -34,7 +38,7 @@ import {
import type { DeployLogEvent } from "./progress.js";
import { withDeployPhase } from "./phase.js";
import type { ResolvedSigner } from "../signer.js";
import type { Env } from "../../config.js";
import { DEFAULT_ENV, type Env } from "../../config.js";
import type { DeployPlan } from "./availability.js";

// ── Events ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -116,6 +120,40 @@ export async function runDeploy(options: RunDeployOptions): Promise<DeployOutcom

options.onEvent({ kind: "plan", approvals: setup.approvals });

// Phone mode: attempt to resolve a BulletIn slot-account signer so chunk
// uploads use the user's own allowance instead of the shared pool. Falls
// back silently to pool if the key is absent or resolution fails for any
// reason — bulletin-deploy handles the pool path internally.
if (options.mode === "phone" && options.userSigner) {
const resolvedEnv = options.env ?? DEFAULT_ENV;
try {
options.onEvent({
kind: "signing",
event: { label: "Approve Bulletin storage allowance" },
} as any);
const slotSigner = await getBulletinAllowanceSigner({
env: resolvedEnv,
ownerAddress: options.userSigner.address,
publishSigner: options.userSigner,
});
const slotKey = await readSlotAccountKey(
resolvedEnv,
options.userSigner.address,
"BulletInAllowance",
);
const slotSs58 = slotKey ? getSlotAccountAddress(slotKey) : undefined;
if (slotSs58) {
setup.bulletinDeployAuthOptions = {
...setup.bulletinDeployAuthOptions,
storageSigner: slotSigner,
storageSignerAddress: slotSs58,
};
}
} catch {
// Slot signer resolution failed — pool fallback handled by bulletin-deploy.
}
}

const counter = createSigningCounter(setup.approvals.length);

const buildAbs = options.buildDir;
Expand Down
19 changes: 18 additions & 1 deletion src/utils/deploy/signerMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
*/

import { DEFAULT_MNEMONIC, type DeployOptions } from "bulletin-deploy";
import type { PolkadotSigner } from "polkadot-api";
import { ss58Encode } from "@parity/product-sdk-address";
import { seedToAccount } from "@parity/product-sdk-keys";
import type { ResolvedSigner } from "../signer.js";
Expand Down Expand Up @@ -95,7 +96,23 @@ export interface DeploySignerSetup {
* phone mode we inject the user's signer so DotNS registration is paid
* for by — and recorded against — their account.
*/
bulletinDeployAuthOptions: Pick<DeployOptions, "signer" | "signerAddress" | "mnemonic">;
bulletinDeployAuthOptions: Pick<DeployOptions, "signer" | "signerAddress" | "mnemonic"> & {
/**
* Slot-account signer for Bulletin storage writes. When present,
* bulletin-deploy routes chunk uploads through this signer's
* allowance instead of the pool. Only populated in phone mode when
* `getBulletinAllowanceSigner` succeeds; pool fallback otherwise.
*
* NOTE: `storageSigner` / `storageSignerAddress` are present in
* `DeployOptions` only from bulletin-deploy v0.8.x onward. The
* intersection is used here because the npm-published 0.7.x types
* don't carry these fields yet; the spread in storage.ts passes them
* through silently to the runtime where they become effective once
* bulletin-deploy is updated.
*/
storageSigner?: PolkadotSigner;
storageSignerAddress?: string;
};

/**
* Signer used to call `registry.publish()` for the playground step.
Expand Down
11 changes: 10 additions & 1 deletion src/utils/deploy/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
type DeployOptions,
type DeployResult,
} from "bulletin-deploy";
import type { PolkadotSigner } from "polkadot-api";
import { DeployLogParser, type DeployLogEvent } from "./progress.js";
import { getChainConfig, type Env } from "../../config.js";

Expand All @@ -53,7 +54,15 @@ export interface StorageDeployOptions {
* Auth options forwarded to bulletin-deploy. Usually produced by
* `resolveSignerSetup()`. May be `{}` for the dev path.
*/
auth: Pick<DeployOptions, "signer" | "signerAddress" | "mnemonic">;
auth: Pick<DeployOptions, "signer" | "signerAddress" | "mnemonic"> & {
/**
* Slot-account signer for Bulletin storage writes. Forwarded to
* bulletin-deploy unchanged; 0.7.x ignores it silently, 0.8.x routes
* chunk uploads through this signer's allowance instead of the pool.
*/
storageSigner?: PolkadotSigner;
storageSignerAddress?: string;
};
/** Emits progress events derived from bulletin-deploy's log output. */
onLogEvent?: (event: DeployLogEvent) => void;
/** Target environment — currently only `testnet` is supported. */
Expand Down
77 changes: 76 additions & 1 deletion src/utils/sessionSigner.test.ts
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 { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { ss58Encode } from "@parity/product-sdk-address";
import { seedToAccount } from "@parity/product-sdk-keys";
import type { UserSession } from "@parity/product-sdk-terminal";
Expand Down Expand Up @@ -127,3 +127,78 @@ describe("init / deploy / playground-app account equivalence", () => {
expect(PLAYGROUND_PRODUCT_ID).toEqual("playground.dot");
});
});

// ────────────────────────────────────────────────────────────────────────────
// signRaw(Payload) anti-regression — guards against accidental revert to the
// signPayload path (which sends 2 MB calldata to mobile wallets).
// ────────────────────────────────────────────────────────────────────────────
describe("createPlaygroundSessionSigner — signRaw(Payload) routing", () => {
type OkResult<T> = { isErr(): false; isOk(): true; value: T };
type ErrResult<E> = { isErr(): true; isOk(): false; error: E };
function okResult<T>(value: T): OkResult<T> {
return { isErr: () => false as const, isOk: () => true as const, value };
}
function errResult<E>(error: E): ErrResult<E> {
return { isErr: () => true as const, isOk: () => false as const, error };
}

function makeSession(opts: {
signRaw?: (req: unknown) => Promise<unknown>;
signPayload?: (req: unknown) => Promise<unknown>;
} = {}) {
const root = seedToAccount("bottom drive obey lake curtain smoke basket hold race lonely fit walk", "");
return {
id: "test",
localAccount: { accountId: new Uint8Array(32), pin: undefined },
remoteAccount: { accountId: root.publicKey, publicKey: root.publicKey, pin: undefined },
rootAccountId: root.publicKey,
signRaw: vi.fn(opts.signRaw ?? (async () => { throw new Error("signRaw not stubbed"); })),
signPayload: vi.fn(opts.signPayload ?? (async () => { throw new Error("signPayload not stubbed"); })),
} as unknown as UserSession & { signRaw: ReturnType<typeof vi.fn>; signPayload: ReturnType<typeof vi.fn> };
}

test("signBytes never calls session.signPayload (anti-regression: message-too-big)", async () => {
const sig = new Uint8Array(64).fill(0xaa);
const session = makeSession({
signRaw: async () => okResult({ signature: sig }),
});
const signer = createPlaygroundSessionSigner(session, {
productId: PLAYGROUND_PRODUCT_ID,
derivationIndex: 0,
});
await signer.signBytes(new Uint8Array([1]));
expect(session.signPayload).not.toHaveBeenCalled();
expect(session.signRaw).toHaveBeenCalled();
const req = (session.signRaw.mock.calls[0] as [{ data: { tag: string } }])[0];
expect(req.data.tag).toBe("Bytes");
});

test("signBytes routes through signRaw with Bytes tag", async () => {
const sig = new Uint8Array(64).fill(0xbb);
const session = makeSession({
signRaw: async () => okResult({ signature: sig }),
});
const signer = createPlaygroundSessionSigner(session, {
productId: PLAYGROUND_PRODUCT_ID,
derivationIndex: 0,
});
const out = await signer.signBytes(new Uint8Array([1, 2, 3]));
expect(out).toEqual(sig);
expect(session.signPayload).not.toHaveBeenCalled();
const req = (session.signRaw.mock.calls[0] as [{ data: { tag: string } }])[0];
expect(req.data.tag).toBe("Bytes");
});

test("mobile rejection surfaces as 'Mobile signing rejected:' prefix", async () => {
const session = makeSession({
signRaw: async () => errResult({ message: "user declined" }),
});
const signer = createPlaygroundSessionSigner(session, {
productId: PLAYGROUND_PRODUCT_ID,
derivationIndex: 0,
});
await expect(signer.signBytes(new Uint8Array([1]))).rejects.toThrow(
"Mobile signing rejected: user declined",
);
});
});
Loading
Loading