diff --git a/src/utils/decentralize/run.ts b/src/utils/decentralize/run.ts index a6cbb0b..3319d00 100644 --- a/src/utils/decentralize/run.ts +++ b/src/utils/decentralize/run.ts @@ -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 } @@ -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 diff --git a/src/utils/deploy/run.test.ts b/src/utils/deploy/run.test.ts index c7f77d4..cc20e6c 100644 --- a/src/utils/deploy/run.test.ts +++ b/src/utils/deploy/run.test.ts @@ -25,6 +25,9 @@ const { detectBuildConfigMock, loadDetectInputMock, withSpanMock, + getBulletinAllowanceSignerMock, + readSlotAccountKeyMock, + getSlotAccountAddressMock, } = vi.hoisted(() => ({ runStorageDeploy: vi.fn< (arg: any) => Promise<{ @@ -57,9 +60,23 @@ const { configFiles: new Set(), })), 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) => { @@ -111,6 +128,9 @@ beforeEach(() => { publishToPlaygroundMock.mockClear(); runBuildMock.mockClear(); withSpanMock.mockClear(); + getBulletinAllowanceSignerMock.mockClear(); + readSlotAccountKeyMock.mockClear(); + getSlotAccountAddressMock.mockClear(); }); describe("runDeploy", () => { @@ -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(); + }); }); diff --git a/src/utils/deploy/run.ts b/src/utils/deploy/run.ts index f32dca6..ea6ae58 100644 --- a/src/utils/deploy/run.ts +++ b/src/utils/deploy/run.ts @@ -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, @@ -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 ─────────────────────────────────────────────────────────────────── @@ -116,6 +120,40 @@ export async function runDeploy(options: RunDeployOptions): Promise; + bulletinDeployAuthOptions: Pick & { + /** + * 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. diff --git a/src/utils/deploy/storage.ts b/src/utils/deploy/storage.ts index 10500e8..67b2242 100644 --- a/src/utils/deploy/storage.ts +++ b/src/utils/deploy/storage.ts @@ -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"; @@ -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; + auth: Pick & { + /** + * 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. */ diff --git a/src/utils/sessionSigner.test.ts b/src/utils/sessionSigner.test.ts index 244027d..0bcaf4d 100644 --- a/src/utils/sessionSigner.test.ts +++ b/src/utils/sessionSigner.test.ts @@ -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"; @@ -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 = { isErr(): false; isOk(): true; value: T }; + type ErrResult = { isErr(): true; isOk(): false; error: E }; + function okResult(value: T): OkResult { + return { isErr: () => false as const, isOk: () => true as const, value }; + } + function errResult(error: E): ErrResult { + return { isErr: () => true as const, isOk: () => false as const, error }; + } + + function makeSession(opts: { + signRaw?: (req: unknown) => Promise; + signPayload?: (req: unknown) => Promise; + } = {}) { + 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; signPayload: ReturnType }; + } + + 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", + ); + }); +}); diff --git a/src/utils/sessionSigner.ts b/src/utils/sessionSigner.ts index 77ea83c..005ce60 100644 --- a/src/utils/sessionSigner.ts +++ b/src/utils/sessionSigner.ts @@ -16,45 +16,25 @@ /** * Session-backed `PolkadotSigner` for the playground product account. * - * **Why a CLI-local builder and not `createSessionSignerForAccount` from - * `@parity/product-sdk-terminal@0.2.1`?** + * **Why `getPolkadotSigner` with `signRaw(Payload)` instead of + * `getPolkadotSignerFromPjs` with `signPayload`?** * - * The SDK's "PR #81 fix" routes tx signing through - * `session.signRaw({ data: { tag: "Payload", value: hex(toSign) } })`. Android - * v1198 (and earlier) ALWAYS applies the `...` anti-phishing - * envelope inside `SignRawInteractor.sign()` — see - * `polkadot-app-android-v2/chains/.../MessageSigningContext.kt::generalUntrustedMessage` - * and `feature/products/impl/.../SignRawInteractor.kt` — so the resulting - * signature is over `${utf8(hex)}`, NOT the bare extrinsic - * payload. The chain reconstructs the bare payload, verifies, and rejects with - * `{ type: "Invalid", value: { type: "BadProof" } }` on EVERY `Revive.map_account` - * (and any other tx) on paseo-next-v2 with the AsPgas extension active. + * The PJS-based approach sent the full 2 MB chunk calldata as the `method` + * field of `signPayload`. Android rejects payloads exceeding a size limit with + * "message too big", causing every large-chunk deploy to fail on mobile wallets. * - * The canonical workaround comes straight from the Android team's own sample - * app at `polkadot-app-android-v2/feature/products/product-sample/src/scripts/products_demo.tsx:773-789`: - * - * 1. Build a PJS-style signer with `getPolkadotSignerFromPjs(address, signPayload, signRaw)`. - * 2. Provide a custom `signPayload` that maps PJS's `SignerPayloadJSON` onto - * host-papp's `SigningPayloadRequest` and forwards via `session.signPayload(...)`. - * Android's `signPayload` handler then reconstructs the full payload itself - * (including AsPgas sponsoring) and signs the bare bytes correctly. - * 3. Wrap the resulting signer so that for `RELAXED_SIGNED_EXTENSIONS` - * (extensions PAPI sees but the PJS adapter can't recognize, e.g. `AsPgas` - * and `AsRingAlias`), we zero out `value` + `additionalSigned` BEFORE PJS - * walks them. That sidesteps PJS's "PJS does not support this - * signed-extension" throw at `@polkadot-api/pjs-signer/dist/from-pjs-account.js:30-32` - * WITHOUT dropping the identifier from `signedExtensions[]` — so android - * still knows to include them and fills in the correct encoding from its - * own runtime view. + * PAPI's `getPolkadotSigner` hashes payloads >256 bytes before calling the raw + * sign function, so the mobile wallet only ever receives ≤32 bytes. The + * `Payload` tag tells Android to sign without the `` + * anti-phishing envelope (which would break the signature for extrinsic + * payloads). * * Replace this whole file with a `product-sdk-terminal` re-export once that - * package's signer uses `session.signPayload` and ships the relaxed-extensions - * wrapper natively. + * package's signer uses the same approach and ships natively. */ -import { getPolkadotSignerFromPjs, type SignerPayloadJSON } from "polkadot-api/pjs-signer"; -import { fromHex, toHex } from "polkadot-api/utils"; -import { ss58Encode } from "@parity/product-sdk-address"; +import { getPolkadotSigner } from "polkadot-api/signer"; +import { toHex } from "polkadot-api/utils"; import type { UserSession } from "@parity/product-sdk-terminal"; import type { PolkadotSigner } from "polkadot-api"; import { deriveProductAccountPublicKey } from "@parity/product-sdk-keys"; @@ -99,117 +79,44 @@ export function derivePlaygroundProductPublicKey( return deriveProductAccountPublicKey(rootAccountId, ref.productId, ref.derivationIndex); } -/** - * Identifiers whose payload PAPI may populate but the PJS adapter doesn't - * recognize. Mirrors `RELAXED_SIGNED_EXTENSIONS` in the polkadot-app sample. - * Add to this set if a future runtime adds another v2-style extension PAPI - * doesn't know about; android's host fills in the actual encoding. - */ -const RELAXED_SIGNED_EXTENSIONS: ReadonlySet = new Set(["AsPgas", "AsRingAlias"]); - -function asHexString(value: string | undefined): `0x${string}` | undefined { - if (value === undefined) return undefined; - // host-papp's SigningPayloadRequest types hex fields as `0x${string}`. - // PJS adapter populates them via toPjsHex / toHex which produce hex strings; - // cast through since the runtime values are guaranteed-prefixed. - return value as `0x${string}`; -} - -/** - * Coerce PJS's `assetId: number | object | undefined` to host-papp's hex shape. - * - * For `ChargeAssetTxPayment` and `AsPgas`, the PJS mapper produces a `0x…` - * string when the asset is set. Other shapes (number / nested object) shouldn't - * surface in paseo-next-v2 today; we coerce defensively. - */ -function coerceAssetId(value: unknown): `0x${string}` | undefined { - if (value === undefined || value === null) return undefined; - if (typeof value === "string" && value.startsWith("0x")) return value as `0x${string}`; - // Defensive fallback: stringify and warn upstream. - return undefined; -} - export function createPlaygroundSessionSigner( session: UserSession, ref: ProductAccountRef, ): PolkadotSigner { - // `session.remoteAccount.accountId` is the wallet's currently-selected - // substrate account (`walletAccount.defaultAccountId()` on Android), NOT - // the product-derived account that actually signs on-chain. Using it as - // `signer.publicKey` would cause every funding / balance lookup / allowance - // marker / display address in this CLI to point at the wallet, while the - // mobile-constructed `signedTransaction` carries a different `From` - // (the product account derived at `/product/{productId}/{idx}`). - // // `session.rootAccountId` is the handshake-time `rootUserAccountId` — // the user's bare-mnemonic keypair public key on current mobile builds // (`deriveRootAccount()` = `derivationPath = null`). See the "Accounts" // section in CLAUDE.md for the host-vs-mobile derivation map. const publicKey = derivePlaygroundProductPublicKey(sessionRootPublicKey(session), ref); - const address = ss58Encode(publicKey); - // Wire-shape identifier passed to host-papp's `signPayload` / `signRaw`. + // Wire-shape identifier passed to host-papp's `signRaw`. // Has to be assembled here (not in derive) because the host-papp message // codec wants the productId/derivationIndex as a separate tuple field. const productAccountId: [string, number] = [ref.productId, ref.derivationIndex]; - const signPayload = async (pjs: SignerPayloadJSON) => { - const result = await session.signPayload({ + const txSign = async (toSign: Uint8Array): Promise => { + // PAPI has already hashed toSign if >256 bytes — mobile receives ≤32 bytes. + const result = await session.signRaw({ productAccountId, - blockHash: asHexString(pjs.blockHash) as `0x${string}`, - blockNumber: asHexString(pjs.blockNumber) as `0x${string}`, - era: asHexString(pjs.era) as `0x${string}`, - genesisHash: asHexString(pjs.genesisHash) as `0x${string}`, - method: asHexString(pjs.method) as `0x${string}`, - nonce: asHexString(pjs.nonce) as `0x${string}`, - specVersion: asHexString(pjs.specVersion) as `0x${string}`, - tip: asHexString(pjs.tip) as `0x${string}`, - transactionVersion: asHexString(pjs.transactionVersion) as `0x${string}`, - signedExtensions: pjs.signedExtensions, - version: pjs.version, - assetId: coerceAssetId(pjs.assetId), - metadataHash: asHexString(pjs.metadataHash), - mode: pjs.mode, - withSignedTransaction: pjs.withSignedTransaction, + data: { tag: "Payload", value: toHex(toSign) }, }); if (result.isErr()) { - throw new Error(`Mobile signing failed: ${result.error.message}`); + throw new Error(`Mobile signing rejected: ${result.error.message}`); } - const data = result.value; - return { - signature: toHex(data.signature), - signedTransaction: data.signedTransaction ? toHex(data.signedTransaction) : undefined, - }; + return result.value.signature; }; - const signRaw = async (payload: { address: string; data: string; type: "bytes" }) => { - if (!payload.data.startsWith("0x")) { - throw new Error("Raw signing payload must be 0x-prefixed hex"); - } + const bytesSign = async (data: Uint8Array): Promise => { const result = await session.signRaw({ productAccountId, - data: { tag: "Bytes", value: fromHex(payload.data as `0x${string}`) }, + data: { tag: "Bytes", value: data }, }); if (result.isErr()) { - throw new Error(`Mobile signing failed: ${result.error.message}`); + throw new Error(`Mobile signing rejected: ${result.error.message}`); } - return { id: 0, signature: toHex(result.value.signature) }; + return result.value.signature; }; - const baseSigner = getPolkadotSignerFromPjs(address, signPayload, signRaw); - - // Relaxed-extensions wrapper — see the file-level comment. - return { - publicKey: baseSigner.publicKey, - signBytes: baseSigner.signBytes, - signTx: (callData, signedExtensions, metadata, atBlockNumber, hasher) => { - const relaxed: typeof signedExtensions = {}; - for (const [identifier, ext] of Object.entries(signedExtensions)) { - relaxed[identifier] = RELAXED_SIGNED_EXTENSIONS.has(identifier) - ? { ...ext, value: new Uint8Array(0), additionalSigned: new Uint8Array(0) } - : ext; - } - return baseSigner.signTx(callData, relaxed, metadata, atBlockNumber, hasher); - }, - }; + const base = getPolkadotSigner(publicKey, "Sr25519", txSign); + return { ...base, signBytes: bytesSign }; }