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

Fix phone-mode `playground deploy` and `playground decentralise` failing with "Mobile transaction signing rejected: message too big" during the Bulletin upload. Bulletin storage chunks (up to 2 MiB each) are now signed with the local Bulletin allowance slot key instead of being routed to the phone, whose signing channel caps messages far below chunk size. The phone is still used for DotNS and registry publish approvals. Also bumps bulletin-deploy to 0.8.3, the first release with `storageSigner` support. If the slot key is missing, deploy now fails fast with a hint to re-run `playground init` instead of retrying chunks against an impossible signer.

Also handles expired phone sessions cleanly. The statement-store allowance that carries every phone interaction lapses ~2 days after login and cannot be renewed remotely; previously an expired session made phone signing hang for minutes and fail with a cryptic "transaction watcher silent" error. Phone signing now fails within a second with a clear "run `playground logout` then `playground init`" message, and `playground deploy` warns up front when the last login is more than 2 days old.

Fixes the Bulletin slot account derivation: the SDK derives the wrong public key from phone-issued 64-byte slot keys (missing schnorrkel scalar normalization), so storage and metadata uploads signed as an address the chain never authorized. This silently dropped phone-mode deploys onto the shared pool account, where transactions race other users' nonces and die with `AncientBirthBlock`. Uploads now sign as the address the phone actually granted the allowance to.

Phone-mode deploys also check the slot's remaining quota against the estimated upload size before starting. An undersized allowance triggers a single Increase approval on the phone up front; if the quota still looks short after that, the deploy warns and proceeds rather than blocking, since the authorization itself is what the chain checks.

Fixes session selection after repeated pairings: the CLI used to operate on the OLDEST persisted session, so after a re-pair, requests (including the `playground init` allowance approval) could be sent into a session the phone no longer serves, disappearing without an error. All flows now use the most recent pairing, and a successful login disconnects leftover stale sessions.
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pnpm lint:license
pnpm test
```

`pnpm build` is the canonical type signal there is no separate `tsc` step. If `lint:license` flags a file you authored, run `./scripts/check-license-headers.sh --fix` to prepend the standard Parity Apache-2.0 header (`SPDX-License-Identifier: Apache-2.0` + `Copyright (C) Parity Technologies (UK) Ltd.`, both lines required). The check script keeps shebangs on line 1 and places the header below them.
`pnpm build` compiles with bun, which STRIPS types without checking them — it is NOT a type signal, and there is no tsc step in CI. The tree carries a known baseline of pre-existing `tsc --noEmit` errors (13 as of June 2026, including the `TypedApi<Paseo_bulletin>` vs `BulletinTypedApi` mismatches in `playground.ts`/`AccountSetup.tsx` and a possibly-null access in `deploy/run.ts`). Before claiming a change complete, run `pnpm exec tsc --noEmit 2>&1 | grep -c "error TS"` and confirm the count did not grow — new errors hide silently otherwise. Burning the baseline down to zero and adding a tsc CI step is an open follow-up. If `lint:license` flags a file you authored, run `./scripts/check-license-headers.sh --fix` to prepend the standard Parity Apache-2.0 header (`SPDX-License-Identifier: Apache-2.0` + `Copyright (C) Parity Technologies (UK) Ltd.`, both lines required). The check script keeps shebangs on line 1 and places the header below them.

## Non-obvious invariants

Expand All @@ -25,7 +25,7 @@ These aren't self-evident from reading the code and have bitten us before. Treat
- **`@novasamatech/*` packages are forced to `0.7.9-4` via `pnpm.overrides`.** They're transitive (via `@parity/product-sdk-terminal`'s `^0.7.7` ranges) and pnpm won't bump transitives across patches. The override aligns the tree on the latest published Novasama line including RFC-0010 `requestResourceAllocation`. Drop the override once product-sdk-terminal bumps its caret natively.
- **`@polkadot-api/json-rpc-provider: ^0.2.0` override is load-bearing.** Removing it splits the lockfile across three versions of `json-rpc-provider` (`0.0.1`/`0.0.4`/`0.2.0`) — different PAPI 2.x transitive consumers ask for different versions. Forcing everyone onto `0.2.0` avoids subtle wire-shape divergence and reduces bundle/process memory.
- **`@parity/dotns-cli@0.6.1` ships a broken publish manifest** declaring `"@polkadot-api/descriptors": "file:.papi/descriptors"` — a workspace path missing from the tarball. pnpm refuses; we redirect that sub-dep to `stubs/papi-descriptors-stub/` (an empty `{}` export). dotns-cli's `dist/cli.js` is a fully-bundled Bun build, so the stub is functionally correct. Remove the override + stub when `@parity/dotns-cli` republishes a clean manifest.
- **`bulletin-deploy` is pinned to an explicit version (`0.7.24`), not `latest`.** A previous `latest` (0.6.8) had a WebSocket-heartbeat bug that tore chunk uploads down mid-flight. The pin avoids ever silently sliding onto a broken `latest`. When bumping, read release notes for changes to `deploy()`, DotNS methods, or the `DeployOptions` we use (`jsMerkle`, `signer`, `signerAddress`, `mnemonic`, `rpc`, `attributes`). Newer releases now also export environment helpers (`loadEnvironments`, `resolveEndpoints`, etc.); we don't consume them — our env table lives in `src/config.ts::CONFIGS`.
- **`bulletin-deploy` is pinned to an explicit version (`0.8.3`), not `latest`.** A previous `latest` (0.6.8) had a WebSocket-heartbeat bug that tore chunk uploads down mid-flight. The pin avoids ever silently sliding onto a broken `latest`. When bumping, read release notes for changes to `deploy()`, DotNS methods, or the `DeployOptions` we use (`jsMerkle`, `signer`, `signerAddress`, `storageSigner`, `storageSignerAddress`, `mnemonic`, `rpc`, `attributes`). Newer releases now also export environment helpers (`loadEnvironments`, `resolveEndpoints`, etc.); we don't consume them — our env table lives in `src/config.ts::CONFIGS`. Do NOT downgrade below 0.8.3: 0.7.30-rc/0.8.0 changed storage routing to use the injected `signer` (so phone-mode chunk uploads would phone-sign and die with "message too big"), and 0.8.3 is the first release with the `storageSigner` slot-key escape hatch.
- **`polkadot-api` is `^2.1.3`** and effectively the only PAPI version in the runtime: the lockfile contains `polkadot-api@1.x` only because `@parity/dotns-cli` declares it, and dotns-cli ships as a single fully-bundled `dist/cli.js` with all deps inlined — never resolved at runtime.

### Network / env
Expand All @@ -36,9 +36,10 @@ These aren't self-evident from reading the code and have bitten us before. Treat

### Deploy / Bulletin

- **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`.
- **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 `createAliceSignerForDevPublish` 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.
- **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.
- **`playground deploy` does NOT pass `jsMerkle: true` today.** bulletin-deploy's pure-JS merkleizer produces CARs containing only raw leaves (DAG-PB blocks are silently dropped by `blockstore-core/memory`'s `getAll()` under `rawLeaves: true` + `wrapWithDirectory: true`) → polkadot-desktop parses zero files → sites return 404. We rely on the Kubo binary path until the upstream merkleizer collects all blocks, not just leaves. `playground init` installs `ipfs`. Trade-off: this temporarily breaks the RevX WebContainer story for the main storage upload — flip `jsMerkle: true` back once `merkleizeJS` is fixed.
Expand All @@ -56,6 +57,9 @@ These aren't self-evident from reading the code and have bitten us before. Treat

### Allowances / session

- **Slot-account signers MUST come from `src/utils/allowances/slotSigner.ts`, never directly from the SDK.** The mobile returns `slotAccountKey` as 64 bytes of schnorrkel `SecretKey::to_bytes()` material and grants the on-chain allowance to the address it derives natively (Android `SlotAccountKey.kt::deriveAccountId`). `@scure/sr25519` expects the ed25519-expanded form (scalar ×8), so `@parity/product-sdk-terminal/host`'s `createSlotAccountSigner` derives a DIFFERENT address that the chain has never granted anything to — signatures "work" but every `TransactionStorage.store` is unauthorized, and bulletin-deploy silently falls back to the shared pool account (nonce races with other users → `AncientBirthBlock` chunk deaths). `allowances/bulletin.ts::correctedSlotSigner` swaps the SDK signer for the normalized one after every `ensureSlotAccountSigner` / `createSlotAccountSigner` call; keep that wrapping until product-sdk fixes the derivation upstream (then delete `slotSigner.ts`). Frozen vectors in `slotSigner.test.ts`; the live proof is on paseo-next-v2 (grant on the normalized address, nothing on the raw one).
- **Phone-mode storage quota is checked BEFORE the upload, but NEVER blocks** (`deploy/storageQuota.ts` + `resolveStorageSignerOptions`'s `quota` param). Slot grants are small (observed: 10 txs / 4 MiB per claim) while CARs run 2 MiB per chunk; mid-upload Payment failures do NOT fall back to the pool (only a first-connection failure does), so the extent is verified up front — an undersized grant triggers one `Increase` tap via `getBulletinAllowanceSigner`. A shortfall after that is WARN-AND-PROCEED: whether the chain enforces the extent at `store()` time is unconfirmed (bulletin-deploy's author: "the allowance doesn't mean anything anymore, the authorization is what counts"), so blocking on those numbers could fail deploys that would succeed. Only a total resolution failure (no slot key, grant declined) aborts. The context itself is best-effort: estimate or client failures return null and skip the check.
- **The statement-store (SSS) allowance is a 1-day renewable resource and is the CHANNEL for every phone interaction**, not a signing permission. `session.createTransaction` / `signRaw` / `requestResourceAllocation` all travel as statements on the People chain; the host's locally derived SSS account needs an on-chain ring slot to submit them. The slot is granted at QR login (the only flow with a direct WebSocket channel) and lapses ~2-3 days later (1-day period + `StmtStoreGraceWindow` of 2 days, runtime PR individuality#1022). It CANNOT be renewed remotely: the renewal request itself rides SSS (circular dependency), so the only remedy is `playground logout` + `playground init`. There is NO on-chain query for SSS ring membership. When expired, the adapter logs `NoAllowanceError` to console.error but never rejects, so calls hang for the SDK's 180s queue timeout. Defenses: `sessionSigner.ts::wrapSignerWithSssFastFail` (intercepts the log line, rejects in ~200ms with the logout/init message) and `loginStamp.ts` + deploy preflight (warn-only when the recorded login is >2 days old; the stamp lives at `~/.polkadot-apps/dot-cli_LoginStamp.json` so logout clears it). Don't add a "renew SSS on error" path: it cannot work.
- **`getSessionSigner()` returns an adapter that keeps the Node event loop alive.** Every caller must invoke the returned `destroy()` when done. Forgetting it manifests as `playground <cmd>` hanging after the work visibly finishes.
- **`requestResourceAllocation` lives in a CLI-local shim** (`src/utils/allowances/host.ts`). `@parity/product-sdk-terminal@0.2.1` does NOT yet re-export the RFC-0010 host call at the package root, but the underlying `UserSession` (from `@novasamatech/host-papp`) does — we call it directly via the raw session on `SessionHandle.userSession`. `@parity/product-sdk-host`'s `requestResourceAllocation` is the in-container variant (browser globals required) and won't work from the CLI. Replace the shim when product-sdk-terminal surfaces it externally.
- **Allowance grant markers live at `~/.polkadot/allowances.json`** (`src/utils/allowances/marker.ts`), mode 0600, sibling to `accounts.json`. RFC-0010 has no on-chain query for allowance status, so we persist `{ env: { ss58Address: { resourceTag: { grantedAt, source } } } }` after a successful host grant. Slot-account private keys for Bulletin / Statement Store live separately in `~/.polkadot/allowance-keys.json` (`src/utils/allowances/slotKeys.ts`), also mode 0600. A marker alone isn't enough to skip `playground init` for slot resources — confirm the matching key exists too. Markers and keys are isolated per env. Keep `source: "host"` as the only value emitted from production code.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"@polkadot-api/sdk-ink": "^0.7.0",
"@scure/sr25519": "^2.2.0",
"@sentry/node": "^9.47.1",
"bulletin-deploy": "0.8.1",
"bulletin-deploy": "0.8.3",
"commander": "^12.0.0",
"ink": "^5.2.1",
"polkadot-api": "^2.1.3",
Expand Down
Loading
Loading