Skip to content

fix: bulletin storage slot-account signer — eliminate mobile signing size errors#238

Open
EnderOfWorlds007 wants to merge 3 commits into
mainfrom
fix/bulletin-slot-signer
Open

fix: bulletin storage slot-account signer — eliminate mobile signing size errors#238
EnderOfWorlds007 wants to merge 3 commits into
mainfrom
fix/bulletin-slot-signer

Conversation

@EnderOfWorlds007
Copy link
Copy Markdown
Collaborator

@EnderOfWorlds007 EnderOfWorlds007 commented Jun 2, 2026

Summary

  • Replaces getPolkadotSignerFromPjs + session.signPayload in src/utils/sessionSigner.ts with PAPI-native getPolkadotSigner + session.signRaw({ tag: "Payload" }).
  • Wires getBulletinAllowanceSigner into phone-mode deploy and decentralize runners. Emits "Approve Bulletin storage allowance" signing event before allocation.
  • Widens StorageDeployOptions.auth and bulletinDeployAuthOptions to carry storageSigner/storageSignerAddress.

Depends on: paritytech/bulletin-deploy#806 (must merge first).


Bulletin Storage Slot-Account Signer

Date: 2026-06-02
Repos: paritytech/bulletin-deploy (PR #791 branch) + paritytech/playground-cli
Status: Design — awaiting implementation


Problem

src/auth/vendor/sessionSigner.ts (bulletin-deploy) and src/utils/sessionSigner.ts
(playground-cli) both use getPolkadotSignerFromPjs + session.signPayload. The PJS
adapter puts the full call data (~2 MB for a storage chunk) in the method field and
sends it to the mobile wallet. Android rejects it: "Mobile signing failed: message too big".

Confirmed in Sentry (7-day window): 2 live events from paritytech/dApp-factory, domain
paseo-tip-jar01, chunk(nonce:0) subscription error: Mobile signing failed: message too big.

Even with a patched signer, routing 30+ storage chunks through an interactive mobile
session is architecturally wrong. The slot-account model — one wallet dialog at login
(or lazily at first deploy), then local sr25519 signing for all uploads — is the correct
design for bulk Bulletin submissions. playground-cli already implements this in
src/utils/allowances/bulletin.ts for its metadata-upload path; this design extends it
to the main runStorageDeploy path and mirrors the pattern in bulletin-deploy.


Scope

Two repos change in lock-step because DeployOptions in bulletin-deploy is a breaking
interface for playground-cli.

bulletin-deploy

Unit Files
A. Session signer vendor update src/auth/vendor/sessionSigner.ts
B. New storageSigner parameter src/deploy.ts (DeployOptions, selectStorageReconnect)
C. Slot signer reader src/storage-signer.ts (new)
D. CLI wiring src/commands/deploy.ts
E. Classifier widening src/telemetry.ts (PR #803 follow-up)

playground-cli

Unit Files
A. Session signer vendor update src/utils/sessionSigner.ts
B. Slot signer in deploy runners src/utils/deploy/run.ts, src/utils/decentralize/run.ts
C. StorageDeployOptions widening src/utils/deploy/storage.ts

Architecture

A — Session signer vendor update (both repos)

The upstream product-sdk signer.ts has been rewritten. The old path:

getPolkadotSignerFromPjs → pjs.method = toHex(callData)  // ~4 MB hex for 2 MB chunk
→ session.signPayload({ method: "0x<4MB>" })              // Android: "message too big"

New path (mirrors current product-sdk signer.ts exactly):

getPolkadotSigner(publicKey, "Sr25519", signCallback)
  → PAPI: toSign.length > 256 ? blake2(toSign) : toSign   // 32 bytes for large tx
  → signCallback(32 bytes)
  → session.signRaw({ tag: "Payload", value: hex(32bytes) })  // no size problem

The Payload tag tells Android to sign the bytes as-is (no <Bytes>…</Bytes>
anti-phishing envelope), which avoids the BadProof failure that broke the previous
signRaw attempt on old Android builds.

The error string changes: "Mobile signing failed:""Mobile signing rejected:".
The signer.message_too_large classifier in src/telemetry.ts (PR #803) must be
widened:

// Before
[/Mobile signing failed.*message too big/i, 'signer.message_too_large'],

// After
[/Mobile signing (?:failed|rejected).*message too big/i, 'signer.message_too_large'],

B — DeployOptions.storageSigner (bulletin-deploy)

Add to DeployOptions:

/** Local slot-account signer for Bulletin chunk uploads. When present, used instead of
 *  pool or mnemonic for storage. DotNS still uses `signer`/`signerAddress`. */
storageSigner?: PolkadotSigner;
storageSignerAddress?: string;

Update selectStorageReconnect — new priority: storageSigner > mnemonic > pool.
The signer field is removed from storage routing entirely; it routes to DotNS only.
This restores the architectural invariant documented at deploy.ts:2309 that the sign-in
lift inadvertently broke.

The slot signer is Bulletin-only — it has nothing to do with the ensureAuthorized /
DotNS authorization machinery in getSignerProvider. A new getSlotSignerProvider
creates a Bulletin WS connection and returns { client, unsafeApi, signer, ss58 } with
no authorization checks. Pool is always the final fallback; no deploy should fail because
of the Bulletin allowance path.

The reconnect thunk uses a committed useSlot flag: once the slot signer fails on the
first connection attempt, every subsequent reconnect uses pool. This prevents signer
drift across chunk uploads (nonce attribution would break if storage switched signers
mid-upload).

function selectStorageReconnect(options: DeployOptions): () => Promise<ProviderResult> {
  if (options.storageSigner && options.storageSignerAddress) {
    let useSlot = true;
    return async () => {
      if (!useSlot) return getProvider();
      try {
        return await getSlotSignerProvider(options.storageSigner!, options.storageSignerAddress!);
      } catch {
        useSlot = false;
        setDeployAttribute("deploy.signer.mode", "pool-fallback");
        return getProvider();
      }
    };
  }
  if (options.mnemonic)
    return () => getDirectProvider(options.mnemonic!, options.derivationPath);
  return () => getProvider();  // pool
  // options.signer intentionally absent — DotNS only
}

Export `__selectStorageProviderModeForTest` is updated to match:

```ts
export function __selectStorageProviderModeForTest(
  options: Pick<DeployOptions, "storageSigner" | "storageSignerAddress" | "mnemonic">
): "storageSigner" | "direct" | "pool"

C — Slot signer reader + writer (bulletin-deploy src/storage-signer.ts)

Reads the product-sdk terminal allowance cache at
~/.polkadot-apps/{sanitize(DOT_DAPP_ID)}_AllowanceKeys.json
(same format as @parity/product-sdk-terminal's host-signer.ts).

// src/storage-signer.ts
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { fromHex } from "@polkadot-api/utils";
import { sr25519CreateDerive } from "@polkadot-labs/hdkd";
import { sr25519, ss58Address } from "@polkadot-labs/hdkd-helpers";
import { getPolkadotSigner } from "polkadot-api/signer";
import type { PolkadotSigner } from "polkadot-api";

// Mirrors product-sdk's sanitizeAppId.
function cacheFilePath(appId: string): string {
  return join(
    homedir(), ".polkadot-apps",
    `${appId.replace(/[^a-zA-Z0-9_.-]/g, "_")}_AllowanceKeys.json`,
  );
}

function signerFromSecret(secret: Uint8Array): PolkadotSigner {
  if (secret.length === 32) {
    const kp = sr25519CreateDerive(secret)("");
    return getPolkadotSigner(kp.publicKey, "Sr25519", async d => kp.sign(d));
  }
  if (secret.length === 64) {
    const publicKey = sr25519.getPublicKey(secret);
    return getPolkadotSigner(publicKey, "Sr25519", async d => sr25519.sign(d, secret));
  }
  throw new Error(`BulletInAllowance: unexpected key length ${secret.length}`);
}

/**
 * Read the BulletInAllowance slot account from the product-sdk terminal cache.
 * Returns null when not cached — caller should trigger requestResourceAllocation.
 *
 * Cache format: @parity/product-sdk-terminal host-cache.ts v1.
 */
export async function readBulletinSlotSigner(
  appId: string,
): Promise<{ signer: PolkadotSigner; ss58: string } | null> {
  let raw: string;
  try { raw = await readFile(cacheFilePath(appId), "utf-8"); }
  catch (e: any) { if (e?.code === "ENOENT") return null; throw e; }
  let cache: any;
  try { cache = JSON.parse(raw); } catch { return null; }
  const entry = cache?.entries?.BulletInAllowance;
  if (!entry?.slotAccountKey) return null;
  const secret = fromHex(entry.slotAccountKey);
  const signer = signerFromSecret(secret);
  return { signer, ss58: ss58Address(signer.publicKey) };
}

All imports (@polkadot-labs/hdkd, @polkadot-labs/hdkd-helpers, @polkadot-api/utils,
polkadot-api/signer) are already direct dependencies on the feat/411-signin-impl
branch. No new deps.

src/storage-signer.ts also needs a writer, because the vendored allocations.ts
intentionally does not persist slot keys ("the host stores them and uses them
transparently on subsequent calls" — its AllocationOutcome.value is typed unknown
and never read). The CLI path must extract the key from the outcome and write it to the
product-sdk terminal cache format itself.

Add to src/storage-signer.ts:

import { writeFile, mkdir } from "node:fs/promises";

/**
 * Extract the BulletInAllowance slot account key from a requestResourceAllocation
 * outcome array. Same shape as playground-cli's extractSlotAccountKey.
 */
export function extractBulletinSlotKey(outcomes: { tag: string; value: unknown }[]): `0x${string}` | null {
  for (const outcome of outcomes) {
    if (outcome.tag !== "Allocated") continue;
    const allocated = outcome.value as { tag?: string; value?: { slotAccountKey?: Uint8Array } } | undefined;
    if (allocated?.tag !== "BulletInAllowance") continue;
    const key = allocated.value?.slotAccountKey;
    if (!(key instanceof Uint8Array)) continue;
    return toHex(key) as `0x${string}`;  // toHex from @polkadot-api/utils (already imported)
  }
  return null;
}

/**
 * Write a BulletInAllowance slot key to the product-sdk terminal cache.
 * Format: @parity/product-sdk-terminal host-cache.ts v1.
 */
export async function writeBulletinSlotKey(appId: string, hexKey: `0x${string}`): Promise<void> {
  const path = cacheFilePath(appId);
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
  // Read-modify-write to preserve other entries in the cache file.
  let existing: any = { version: 1, entries: {} };
  try { existing = JSON.parse(await readFile(path, "utf-8")); } catch { /* start fresh */ }
  existing.entries ??= {};
  existing.entries.BulletInAllowance = { tag: "BulletInAllowance", slotAccountKey: hexKey };
  await writeFile(path, `${JSON.stringify(existing, null, 2)}\n`, { mode: 0o600 });
}

(dirname comes from node:path, already imported.)

D — CLI wiring (bulletin-deploy src/commands/deploy.ts)

After resolveSigner, if a session exists:

// Resolve slot-account signer for Bulletin storage.
// Pool is always the final fallback — no failure here should abort the deploy.
let slotResult = await readBulletinSlotSigner(DOT_DAPP_ID);

if (!slotResult && resolvedSigner.source === "session") {
  console.log("Requesting Bulletin storage allowance — check your phone to approve");
  try {
    // Get the live session (needed for requestResourceAllocation's UserSession arg).
    const sessionHandle = await authClient.getSessionSigner();
    if (sessionHandle) {
      const outcomes = await requestResourceAllocation(
        sessionHandle.userSession,
        DOT_PRODUCT_ID,
        [{ tag: "BulletInAllowance", value: undefined }],
      );
      // The vendored allocations.ts intentionally does not persist slot keys —
      // extract and write to the product-sdk terminal cache ourselves.
      const hexKey = extractBulletinSlotKey(outcomes);
      if (hexKey) {
        await writeBulletinSlotKey(DOT_DAPP_ID, hexKey);
        slotResult = await readBulletinSlotSigner(DOT_DAPP_ID);
      }
    }
  } catch {
    // Allocation failed or declined — fall back to pool silently.
  }
}

if (slotResult) {
  deployOptions.storageSigner = slotResult.signer;
  deployOptions.storageSignerAddress = slotResult.ss58;
}
// If slotResult is null for any reason (no session, declined, allocation error,
// key not extractable), selectStorageReconnect falls through to pool.

Imports added to the deploy command:

  • requestResourceAllocation from src/auth/index.ts (already exported, signature is (session: UserSession, productId: string, resources, onExisting) => Promise<AllocationOutcome[]>)
  • extractBulletinSlotKey, writeBulletinSlotKey from src/storage-signer.ts (new)
  • DOT_PRODUCT_ID from src/auth-config.ts (already exported)

Pool is always the final fallback. Any failure — declined, timeout, key not in outcome,
write error — is caught and swallowed; deploy continues with pool storage.

E — Slot signer resolution in playground-cli runners

resolveSignerSetup stays synchronous — no signature change. The slot signer resolution
happens in the async deploy runners, which already have env, ownerAddress
(userSigner.address), and userSigner in scope.

src/utils/deploy/run.ts and src/utils/decentralize/run.ts (phone mode):

// After resolveSignerSetup, before runStorageDeploy:
if (opts.mode === "phone" && opts.userSigner) {
  onEvent?.({ kind: "signing", event: { label: "Approve Bulletin storage allowance" } });
  const slotSigner = await getBulletinAllowanceSigner({
    env: opts.env,
    ownerAddress: opts.userSigner.address,
    publishSigner: opts.userSigner,
  });
  setup.bulletinDeployAuthOptions = {
    ...setup.bulletinDeployAuthOptions,
    storageSigner: slotSigner,
    storageSignerAddress: getSlotAccountAddress(
      await readSlotAccountKey(opts.env, opts.userSigner.address, "BulletInAllowance")!
    ),
  };
}

getBulletinAllowanceSigner handles: cache miss → requestResourceAllocation (phone
dialog) → storeSlotAccountKeysFromOutcomes → authorization check → re-allocate with
Ignore if not authorized. The onEvent signing emission before the call drives the
TUI's "check your phone" callout; it fires only when allocation is needed (the function
returns immediately from cache when the key exists).

Note: the Increase re-allocation branch inside getBulletinAllowanceSigner fires when
authorized && remainingTransactions == 0. In practice Bulletin grants a large
transactions_allowance, so remaining stays positive for any authorized account — the
Increase path is dead in practice. Leave the function unchanged; aligning the check
to status.authorized only is a separate cleanup.

src/utils/deploy/storage.tsStorageDeployOptions.auth widened:

auth: Pick<DeployOptions, "signer" | "signerAddress" | "mnemonic" | "storageSigner" | "storageSignerAddress">;

DeploySignerSetup.bulletinDeployAuthOptions in signerMode.ts similarly widens its
Pick to include the two new fields.


Data flow summary

bulletin-deploy CLI

login  → requestResourceAllocation(BulletInAllowance)
         → ~/.polkadot-apps/dot-cli_AllowanceKeys.json

deploy → resolveSigner()                    // session signer → DotNS only
       → readBulletinSlotSigner(DOT_DAPP_ID)
           null → "check your phone to approve"
                → requestResourceAllocation()
                → read again
       → deploy({ signer,           // DotNS (phone taps)
                  storageSigner })  // Bulletin storage (local sr25519, no taps)

playground-cli phone mode

runDeploy/runDecentralize
  → resolveSignerSetup()              // bulletinDeployAuthOptions: signer only
  → emit "Approve Bulletin storage allowance" signing event (if allocation needed)
  → getBulletinAllowanceSigner()      // reads ~/.polkadot/allowance-keys.json
      null → requestResourceAllocation() → store → checkAuth → (re-allocate if not authorized)
      → createSlotAccountSigner(key)  // local sr25519
  → runStorageDeploy({ auth: { signer, signerAddress,         // DotNS
                                storageSigner, storageSignerAddress } })  // storage
      → bulletinDeploy() → storeChunk ×N (local, no phone)
                         → DotNS flow  (phone taps as before)

Fallback chain (both paths): storageSignermnemonic → pool.
signer never enters storage routing.


Error handling

Pool is always the final fallback. No deploy should fail because of the Bulletin
allowance path.

Condition Behaviour
Cache file absent or corrupt readBulletinSlotSigner returns null → pool
User declines phone dialog (bulletin-deploy) allocation error caught → pool
Allocation fails for any reason error caught → pool
getSlotSignerProvider throws (connection, not authorized, anything) caught in selectStorageReconnect → pool
Slot account not authorized on Bulletin (playground-cli) getBulletinAllowanceSigner re-allocates with Ignore; if still fails → propagates to runner, which should catch and fall back to pool

Testing

bulletin-deploy

  • src/storage-signer.ts: unit tests — (1) cache absent → null, (2) valid 32-byte
    key → correct SS58, (3) valid 64-byte key → correct SS58, (4) corrupt JSON → null,
    (5) missing BulletInAllowance entry → null.
  • src/deploy.ts __selectStorageProviderModeForTest: update existing tests; add
    cases for storageSigner present → "storageSigner", absent → existing "direct"/
    "pool" behaviour unchanged.
  • src/auth/vendor/sessionSigner.ts: mirror the product-sdk host-signer.ts tests —
    (1) signTx routes through signRaw with Payload tag, never signPayload; (2)
    signBytes routes through signRaw with Bytes tag; (3) error → "Mobile signing rejected:" prefix.

playground-cli

  • src/utils/sessionSigner.ts: same three tests as bulletin-deploy vendor above.
  • src/utils/deploy/signerMode.ts: existing snapshot tests unchanged (function stays
    sync, output shape unchanged for DotNS fields).
  • src/utils/deploy/run.ts: new test — phone mode with a pre-cached slot key calls
    getBulletinAllowanceSigner and passes storageSigner to runStorageDeploy; dev mode
    does not call getBulletinAllowanceSigner.

Open items

  1. product-sdk signer.ts version pin: confirm @parity/product-sdk-terminal on
    both branches is at or above the version containing the Payload-tag rewrite before
    merging. The old signPayload path is removed in that version.

  2. storageSignerAddress derivation in playground-cli runner:
    getBulletinAllowanceSigner returns a PolkadotSigner but not the SS58. Use
    getSlotAccountAddress(key) from slotKeys.ts immediately after reading the key.
    To avoid a double read, add a thin wrapper that returns both { signer, ss58 }, or
    call readSlotAccountKey again (cheap, no WS).

  3. AuthClient.getSessionSigner creates its own adapter: the getSessionSigner()
    call in the CLI wiring tears down and recreates a terminal adapter. If resolveSigner
    already called it (to produce the DotNS signer), confirm whether a second call is
    safe or whether the session handle from the first call can be threaded through instead.

… PAPI-native signRaw(Payload)

Removes the signPayload path (which sent 2 MB chunk calldata to mobile wallets,
triggering "message too big" rejections). PAPI's getPolkadotSigner hashes
payloads >256 bytes before calling the raw sign function, so mobile receives ≤32
bytes. The Payload tag tells Android to sign without the anti-phishing envelope.

Removes RELAXED_SIGNED_EXTENSIONS wrapper, asHexString, coerceAssetId helpers,
and the getPolkadotSignerFromPjs import (pjs-signer). Adds 3 anti-regression
tests: signPayload-never-called, Bytes-tag routing, rejection error prefix.
…torageSigner to runStorageDeploy

In phone mode, both `runDeploy` and `runDecentralize` now attempt to
resolve the user's BulletIn slot-account signer via
`getBulletinAllowanceSigner` before passing auth to `runStorageDeploy`.
When the slot key is found, `storageSigner` + `storageSignerAddress` are
injected into `bulletinDeployAuthOptions`; any resolution failure falls
through silently so bulletin-deploy's pool path remains the fallback.

`bulletinDeployAuthOptions` in `DeploySignerSetup` and `StorageDeployOptions.auth`
are widened with an intersection type to carry the two new optional fields.
bulletin-deploy 0.7.x ignores them at runtime; the routing becomes effective
once the Task-3 version (feat/411-signin-impl) is published to npm.

3 new tests cover: slot signer injected in phone mode, skipped in dev
mode, and failure swallowed with pool fallback intact.
Emit { kind: "signing", event: { label: "Approve Bulletin storage allowance" } }
before calling getBulletinAllowanceSigner in both deploy and decentralize runners,
so the TUI shows "check your phone" while the allocator is pending. Test updated.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 2, 2026

Dev build ready — try this branch:

curl -fsSL https://raw.githubusercontent.com/paritytech/playground-cli/main/install.sh | VERSION=dev/fix/bulletin-slot-signer bash

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 2, 2026

E2E Test Pass · ❌ FAIL

Tag: e2e-ci-pr · Branch: fix/bulletin-slot-signer · Commit: 8a89426 · Run logs

Cell Result Time
pr-preflight ✅ PASS 3m14s
pr-deploy-cdm ✅ PASS 3m21s
pr-deploy-frontend ❌ FAIL 11m46s
pr-install ✅ PASS 0m54s
pr-deploy-foundry ✅ PASS 0m48s
pr-init-session ✅ PASS 1m55s
pr-mod ❌ FAIL 2m24s
${{ matrix.cell }} ⏭️ SKIP 0m00s
${{ matrix.cell }} ⏭️ SKIP 0m00s

Sentry traces: view spans for this run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant