From 7305c736fef91fac44a27543c46f6488049ec63b Mon Sep 17 00:00:00 2001 From: UtkarshBhardwaj007 Date: Thu, 4 Jun 2026 15:07:31 +0100 Subject: [PATCH] fix: phone-mode deploy signing, slot key derivation, and session selection - Bump bulletin-deploy to 0.8.3 and pass the BulletInAllowance slot key as storageSigner so Bulletin chunk uploads never route through the phone. The statement-store channel caps messages far below the 2 MiB chunk size, which surfaced as 'Mobile transaction signing rejected: message too big' with no prompt ever reaching the phone. - Derive the slot signer with schnorrkel x8 scalar normalization. The SDK's createSlotAccountSigner derives an address the chain never authorized (verified on-chain), which silently dropped storage onto the shared pool account where nonce races killed chunks with AncientBirthBlock. - Check the slot's quota against the estimated upload size before starting, with a single Increase approval on the phone when short; warn and proceed on a residual shortfall rather than blocking. - Fast-fail phone signing within ~200ms when the statement-store allowance has expired (it lapses ~2 days after login and cannot be renewed remotely), with a logout/init remedy, instead of hanging for minutes on a misleading 'transaction watcher silent' timeout. - Record the login time and warn at deploy preflight when the session is older than 2 days. - Select the newest paired session everywhere and prune stale sessions on login. Requests no longer disappear into dead sessions after re-pairing, which is what made 'pg init' allowance approvals never reach the phone. - Document the bun-does-not-typecheck reality and the current tsc error baseline in CLAUDE.md. --- .../phone-deploy-storage-slot-signer.md | 13 ++ CLAUDE.md | 10 +- package.json | 2 +- pnpm-lock.yaml | 43 ++++- src/commands/deploy/index.ts | 12 ++ src/utils/allowances/bulletin.test.ts | 85 ++++++++++ src/utils/allowances/bulletin.ts | 45 ++++- src/utils/allowances/slotSigner.test.ts | 128 ++++++++++++++ src/utils/allowances/slotSigner.ts | 118 +++++++++++++ src/utils/auth.connect.test.ts | 98 ++++++++++- src/utils/auth.ts | 40 ++++- src/utils/decentralize/run.test.ts | 120 ++++++++++++- src/utils/decentralize/run.ts | 27 ++- src/utils/deploy/run.test.ts | 57 +++++++ src/utils/deploy/run.ts | 44 ++++- src/utils/deploy/signerMode.test.ts | 157 +++++++++++++++++- src/utils/deploy/signerMode.ts | 100 ++++++++++- src/utils/deploy/storage.ts | 10 +- src/utils/deploy/storageQuota.test.ts | 54 ++++++ src/utils/deploy/storageQuota.ts | 118 +++++++++++++ src/utils/loginStamp.test.ts | 85 ++++++++++ src/utils/loginStamp.ts | 90 ++++++++++ src/utils/sessionSigner.test.ts | 116 ++++++++++++- src/utils/sessionSigner.ts | 85 +++++++++- 24 files changed, 1617 insertions(+), 40 deletions(-) create mode 100644 .changeset/phone-deploy-storage-slot-signer.md create mode 100644 src/utils/allowances/slotSigner.test.ts create mode 100644 src/utils/allowances/slotSigner.ts create mode 100644 src/utils/deploy/storageQuota.test.ts create mode 100644 src/utils/deploy/storageQuota.ts create mode 100644 src/utils/loginStamp.test.ts create mode 100644 src/utils/loginStamp.ts diff --git a/.changeset/phone-deploy-storage-slot-signer.md b/.changeset/phone-deploy-storage-slot-signer.md new file mode 100644 index 0000000..9372f47 --- /dev/null +++ b/.changeset/phone-deploy-storage-slot-signer.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 96f3831..328440d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 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 @@ -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 @@ -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
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-` 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. @@ -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 ` 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. diff --git a/package.json b/package.json index a1b939b..f047db4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ae6b70..75986c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,8 +71,8 @@ importers: specifier: ^9.47.1 version: 9.47.1 bulletin-deploy: - specifier: 0.8.1 - version: 0.8.1(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3) + specifier: 0.8.3 + version: 0.8.3(@novasamatech/host-api-wrapper@0.8.4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@novasamatech/host-api@0.7.9)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3) commander: specifier: ^12.0.0 version: 12.1.0 @@ -1269,6 +1269,9 @@ packages: '@parity/product-sdk-storage@0.1.5': resolution: {integrity: sha512-TKW7HesTCihDtR1usKg/xMhKiO1kVPJej0bDhXXKTHCDfVWkdPR1A//oG9b+pQtswVLFm4k+FanFOt8lAQMBDA==} + '@parity/product-sdk-terminal@0.2.1': + resolution: {integrity: sha512-xcuKoOMHETwHBefEeNSMDqzL+AQFoTmK3i6JBwsvGhpijwJ8QDdoEMhilYiMKnmoyDml9nu/VCcPUKw3Am0zow==} + '@parity/product-sdk-terminal@0.3.0': resolution: {integrity: sha512-m5OP+G5osqzE+jtPaLp/RxAsYQ8B7Jiiw8jMOOJdaRni30bV07nSPwxHDDNYAefexW8zt90U/qHalYBG/C1XDw==} @@ -2270,8 +2273,8 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bulletin-deploy@0.8.1: - resolution: {integrity: sha512-sINsYTeZ9ZIza5tLWZ10k0hAWnNYMHWbdfQWyKVQoL6K0jLCkEgRWumbX9qr9JdY2L3E3BR59vZunAuPd4y+sA==} + bulletin-deploy@0.8.3: + resolution: {integrity: sha512-PT2dzazWrXo8fpq2PgQlGSmyW6AjpQm2cf1y/HiEhCmatWTwdWMuapj6en3sbJGGirTSsaACRhBt3PJtxBLWQg==} engines: {node: '>=22'} hasBin: true @@ -6859,6 +6862,30 @@ snapshots: - supports-color - utf-8-validate + '@parity/product-sdk-terminal@0.2.1(patch_hash=881abfac101745190526378af2ef8b2bffee3a6b8743272b706f434568e806d3)(esbuild@0.27.7)(rxjs@7.8.2)': + dependencies: + '@noble/ciphers': 2.2.0 + '@noble/curves': 2.2.0 + '@noble/hashes': 2.2.0 + '@novasamatech/host-papp': 0.7.9(esbuild@0.27.7)(rxjs@7.8.2) + '@novasamatech/statement-store': 0.7.9(esbuild@0.27.7)(rxjs@7.8.2) + '@novasamatech/storage-adapter': 0.7.9 + '@parity/product-sdk-logger': 0.1.1 + '@polkadot-api/utils': 0.4.0 + '@polkadot-api/ws-provider': 0.9.0(rxjs@7.8.2) + '@polkadot-labs/hdkd-helpers': 0.0.27 + nanoid: 5.1.11 + neverthrow: 8.2.0 + polkadot-api: 2.1.5(esbuild@0.27.7)(rxjs@7.8.2) + qrcode: 1.5.4 + scale-ts: 1.6.1 + transitivePeerDependencies: + - bufferutil + - esbuild + - rxjs + - supports-color + - utf-8-validate + '@parity/product-sdk-terminal@0.3.0(patch_hash=881abfac101745190526378af2ef8b2bffee3a6b8743272b706f434568e806d3)(esbuild@0.27.7)(rxjs@7.8.2)': dependencies: '@noble/ciphers': 2.2.0 @@ -8390,11 +8417,15 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bulletin-deploy@0.8.1(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3): + bulletin-deploy@0.8.3(@novasamatech/host-api-wrapper@0.8.4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@novasamatech/host-api@0.7.9)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2)(typescript@5.9.3): dependencies: '@ipld/car': 5.4.3 '@ipld/dag-pb': 4.1.5 '@noble/hashes': 1.8.0 + '@parity/product-sdk-address': 0.1.1 + '@parity/product-sdk-keys': 0.3.3(@novasamatech/host-api-wrapper@0.8.4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@novasamatech/host-api@0.7.9)(esbuild@0.27.7)(rxjs@7.8.2) + '@parity/product-sdk-terminal': 0.2.1(patch_hash=881abfac101745190526378af2ef8b2bffee3a6b8743272b706f434568e806d3)(esbuild@0.27.7)(rxjs@7.8.2) + '@parity/product-sdk-tx': 0.2.7(@novasamatech/host-api-wrapper@0.8.4(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.7)(rxjs@7.8.2))(@novasamatech/host-api@0.7.9)(esbuild@0.27.7)(rxjs@7.8.2) '@polkadot-api/metadata-builders': 0.14.3 '@polkadot-api/substrate-bindings': 0.20.3 '@polkadot-labs/hdkd': 0.0.28 @@ -8410,6 +8441,8 @@ snapshots: verifiablejs: 1.3.0-beta.4 viem: 2.52.0(typescript@5.9.3) transitivePeerDependencies: + - '@novasamatech/host-api' + - '@novasamatech/host-api-wrapper' - '@polkadot/util' - bufferutil - encoding diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index fff8548..e93af23 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -19,6 +19,7 @@ import { Command, Option } from "commander"; import { render } from "ink"; import { renderSummaryText } from "./summary.js"; import { captureWarning, errorMessage, withSpan } from "../../telemetry.js"; +import { readLoginStampMs, staleSessionWarning } from "../../utils/loginStamp.js"; import { resolveSigner, SignerNotAvailableError, type ResolvedSigner } from "../../utils/signer.js"; import { getConnection, destroyConnection } from "../../utils/connection.js"; import { checkMapping } from "../../utils/account/mapping.js"; @@ -257,6 +258,17 @@ async function preflight(opts: { // 0.7.19 surfaces a clear "Payment" error if the host's allowance is // missing — the user runs `dot init` to re-request. + // Warn-only staleness heuristic for the statement-store allowance (the + // channel every phone tap rides). It expires ~2 days after login and has + // no on-chain query, so the recorded login time is the best signal we + // have. Never blocks: a wrong guess costs one stderr line, and the SSS + // fast-fail in sessionSigner.ts catches the real expiry at signing time. + // Skipped for dev mode (no phone taps). Runs before the TUI mounts. + if (opts.mode !== "dev") { + const warning = staleSessionWarning(await readLoginStampMs(), Date.now()); + if (warning) process.stderr.write(`${warning}\n`); + } + return signer; } diff --git a/src/utils/allowances/bulletin.test.ts b/src/utils/allowances/bulletin.test.ts index e27354d..d7d729f 100644 --- a/src/utils/allowances/bulletin.test.ts +++ b/src/utils/allowances/bulletin.test.ts @@ -23,11 +23,13 @@ const { createSlotAccountSignerMock, ensureSlotAccountSignerMock, requestResourceAllocationMock, + readCachedBulletinSlotSignerMock, } = vi.hoisted(() => ({ checkAuthorizationMock: vi.fn(), createSlotAccountSignerMock: vi.fn(), ensureSlotAccountSignerMock: vi.fn(), requestResourceAllocationMock: vi.fn(), + readCachedBulletinSlotSignerMock: vi.fn(), })); vi.mock("@parity/product-sdk-cloud-storage", () => ({ @@ -40,6 +42,10 @@ vi.mock("@parity/product-sdk-terminal/host", () => ({ requestResourceAllocation: requestResourceAllocationMock, })); +vi.mock("./slotSigner.js", () => ({ + readCachedBulletinSlotSigner: readCachedBulletinSlotSignerMock, +})); + import { cachedBulletinSlotAuthorization, getBulletinAllowanceSigner, @@ -78,6 +84,10 @@ beforeEach(() => { createSlotAccountSignerMock.mockReset(); ensureSlotAccountSignerMock.mockReset(); requestResourceAllocationMock.mockReset(); + readCachedBulletinSlotSignerMock.mockReset(); + // Default: no local cache read available — every existing test exercises + // the SDK-signer fallback path unchanged. + readCachedBulletinSlotSignerMock.mockResolvedValue(null); }); describe("getBulletinAllowanceSigner", () => { @@ -203,7 +213,82 @@ describe("getBulletinAllowanceSigner", () => { }); }); +describe("getBulletinAllowanceSigner — corrected slot derivation", () => { + // The SDK's createSlotAccountSigner derives the WRONG public key for + // 64-byte phone-issued keys (missing schnorrkel x8 normalization), so the + // signer actually used must come from readCachedBulletinSlotSigner. The + // pubkeys differ so the assertions can prove which one won. + const CORRECTED_PUBLIC_KEY = new Uint8Array(32).fill(9); + const CORRECTED_SIGNER = { publicKey: CORRECTED_PUBLIC_KEY } as any; + + it("prefers the cache-derived signer over the SDK's mis-derived one", async () => { + ensureSlotAccountSignerMock.mockResolvedValue(SLOT_SIGNER); + readCachedBulletinSlotSignerMock.mockResolvedValue(CORRECTED_SIGNER); + + const signer = await getBulletinAllowanceSigner({ publishSigner: sessionSigner() }); + + expect(signer).toBe(CORRECTED_SIGNER); + // The SDK ensure call still runs first — it owns allocation + caching. + expect(ensureSlotAccountSignerMock).toHaveBeenCalledTimes(1); + }); + + it("reads the cache from the ADAPTER's storage dir and appId, not hardcoded defaults", async () => { + // ensureSlotAccountSigner writes its cache to the adapter's storage + // namespace. Reading from a hardcoded ~/.polkadot-apps/dot-cli_* path + // would silently fall back to the SDK's wrong-address signer the + // moment an adapter uses a custom storageDir or appId — re-arming the + // exact bug the corrected derivation exists to fix. + ensureSlotAccountSignerMock.mockResolvedValue(SLOT_SIGNER); + readCachedBulletinSlotSignerMock.mockResolvedValue(CORRECTED_SIGNER); + const adapter = { appId: "custom-app", storageDir: "/custom/dir" } as any; + const publishSigner = { ...sessionSigner(), adapter }; + + await getBulletinAllowanceSigner({ publishSigner }); + + expect(readCachedBulletinSlotSignerMock).toHaveBeenCalledWith("/custom/dir", "custom-app"); + }); + + it("runs the authorization check against the corrected address, not the SDK's", async () => { + ensureSlotAccountSignerMock.mockResolvedValue(SLOT_SIGNER); + readCachedBulletinSlotSignerMock.mockResolvedValue(CORRECTED_SIGNER); + checkAuthorizationMock.mockResolvedValue({ + authorized: true, + remainingTransactions: 1, + remainingBytes: 100n, + expiration: 1, + }); + + await getBulletinAllowanceSigner({ + publishSigner: sessionSigner(), + bulletinApi: {} as any, + requiredBytes: 50, + }); + + const checkedAddress = checkAuthorizationMock.mock.calls[0][1] as string; + const { ss58Encode } = await import("@parity/product-sdk-address"); + expect(checkedAddress).toBe(ss58Encode(CORRECTED_PUBLIC_KEY)); + expect(checkedAddress).not.toBe(ss58Encode(PUBLIC_KEY)); + }); +}); + describe("cachedBulletinSlotAuthorization", () => { + it("checks the corrected address when the cache is readable", async () => { + const CORRECTED = { publicKey: new Uint8Array(32).fill(9) } as any; + createSlotAccountSignerMock.mockResolvedValue(SLOT_SIGNER); + readCachedBulletinSlotSignerMock.mockResolvedValue(CORRECTED); + checkAuthorizationMock.mockResolvedValue({ + authorized: true, + remainingTransactions: 1, + remainingBytes: 100n, + expiration: 1, + }); + + const result = await cachedBulletinSlotAuthorization({} as any, {} as any, 50); + + const { ss58Encode } = await import("@parity/product-sdk-address"); + expect(result?.address).toBe(ss58Encode(new Uint8Array(32).fill(9))); + }); + it("returns null on a cache miss without touching the wire", async () => { createSlotAccountSignerMock.mockResolvedValue(null); diff --git a/src/utils/allowances/bulletin.ts b/src/utils/allowances/bulletin.ts index 0e6f373..06d2b7a 100644 --- a/src/utils/allowances/bulletin.ts +++ b/src/utils/allowances/bulletin.ts @@ -27,8 +27,12 @@ import { type AllocatableResource, } from "@parity/product-sdk-terminal/host"; import type { ResolvedSigner } from "../signer.js"; +import { readCachedBulletinSlotSigner } from "./slotSigner.js"; -const BULLETIN_RESOURCE: AllocatableResource = { tag: "BulletInAllowance", value: undefined }; +export const BULLETIN_RESOURCE: AllocatableResource = { + tag: "BulletInAllowance", + value: undefined, +}; const INIT_HINT = 'Run "playground init" to grant allowances.'; @@ -76,7 +80,30 @@ export async function cachedBulletinSlotAuthorization( ): Promise { const slotSigner = await createSlotAccountSigner(adapter, BULLETIN_RESOURCE); if (!slotSigner) return null; - return getBulletinSlotAuthorization(bulletinApi, slotSigner, requiredBytes); + return getBulletinSlotAuthorization( + bulletinApi, + await correctedSlotSigner(slotSigner, adapter), + requiredBytes, + ); +} + +/** + * Swap the SDK-built slot signer for one derived with the correct schnorrkel + * normalization (see `slotSigner.ts` — the SDK's `createSlotAccountSigner` + * derives the wrong public key for 64-byte phone-issued keys, an address the + * chain has never granted anything to). Falls back to the SDK signer when the + * local cache is unreadable, which preserves today's behavior. Remove once + * product-sdk-terminal fixes the derivation upstream. + * + * The cache is read from the ADAPTER's storage namespace (its readonly + * `storageDir`/`appId` fields) — the same place `ensureSlotAccountSigner` + * just wrote it — never from hardcoded defaults. + */ +async function correctedSlotSigner( + sdkSigner: PolkadotSigner, + adapter: NonNullable, +): Promise { + return (await readCachedBulletinSlotSigner(adapter.storageDir, adapter.appId)) ?? sdkSigner; } function requireSession(publishSigner: ResolvedSigner) { @@ -105,8 +132,13 @@ export async function getBulletinAllowanceSigner({ const { userSession, adapter } = requireSession(publishSigner); - // Cache hit → local sr25519 signer; miss → one phone approval. - let slotSigner = await ensureSlotAccountSigner(userSession, adapter, BULLETIN_RESOURCE); + // Cache hit → local sr25519 signer; miss → one phone approval. The SDK + // call owns allocation + caching; the signer itself is rebuilt from the + // cached key with the correct derivation (see correctedSlotSigner). + let slotSigner = await correctedSlotSigner( + await ensureSlotAccountSigner(userSession, adapter, BULLETIN_RESOURCE), + adapter, + ); if (!bulletinApi) return slotSigner; let authorization = await getBulletinSlotAuthorization(bulletinApi, slotSigner, requiredBytes); @@ -116,7 +148,10 @@ export async function getBulletinAllowanceSigner({ await requestResourceAllocation(userSession, adapter, [BULLETIN_RESOURCE], { onExisting: "Increase", }); - slotSigner = await ensureSlotAccountSigner(userSession, adapter, BULLETIN_RESOURCE); + slotSigner = await correctedSlotSigner( + await ensureSlotAccountSigner(userSession, adapter, BULLETIN_RESOURCE), + adapter, + ); authorization = await getBulletinSlotAuthorization(bulletinApi, slotSigner, requiredBytes); } diff --git a/src/utils/allowances/slotSigner.test.ts b/src/utils/allowances/slotSigner.test.ts new file mode 100644 index 0000000..32d5e27 --- /dev/null +++ b/src/utils/allowances/slotSigner.test.ts @@ -0,0 +1,128 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { toHex } from "polkadot-api/utils"; +import { ss58Encode } from "@parity/product-sdk-address"; +import * as scure from "@scure/sr25519"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { readCachedBulletinSlotSigner, slotSignerFromSecret } from "./slotSigner.js"; + +// Synthetic 64-byte schnorrkel-form secret (scalar half kept small so the x8 +// normalization cannot overflow). The expected SS58 strings were computed +// INDEPENDENTLY of the implementation (scure.getPublicKey over the normalized +// and raw forms respectively) and frozen here. The on-chain ground truth this +// encodes: the mobile app grants the Bulletin allowance to the address derived +// with native schnorrkel semantics from SecretKey::to_bytes() material, which +// equals the scure derivation only AFTER the x8 scalar normalization +// (verified live on paseo-next-v2 against a real phone-issued grant; mirrors +// bulletin-deploy's storage-signer.ts). +function syntheticKey64(): Uint8Array { + const key = new Uint8Array(64); + for (let i = 0; i < 64; i++) key[i] = (i + 1) & 0xff; + key[31] = 0x05; + return key; +} +const NORMALIZED_SS58 = "5ExnAobD7b4JrdLDxD2n1fDxDGYNVG6yxqR2u1wJpZGq7jQB"; +const RAW_SS58 = "5CLqjRkNgmLe3csvp73rgGCqKdS7NYmk93c9JZ7udPdS3anY"; + +describe("slotSignerFromSecret", () => { + it("64-byte key: derives the schnorrkel-normalized address, not the raw scure one", () => { + const signer = slotSignerFromSecret(syntheticKey64()); + const address = ss58Encode(signer.publicKey); + expect(address).toBe(NORMALIZED_SS58); + // The unnormalized derivation is precisely the bug this module fixes + // (product-sdk's createSlotAccountSigner derives this address, which + // the chain has never granted anything to). + expect(address).not.toBe(RAW_SS58); + }); + + it("64-byte key: signatures verify against the derived public key", async () => { + const signer = slotSignerFromSecret(syntheticKey64()); + const payload = new TextEncoder().encode("chunk payload"); + const signature = await signer.signBytes(payload); + // PAPI's signBytes applies the ... anti-phishing + // envelope before signing; verify against the wrapped form. + const enc = new TextEncoder(); + const wrapped = new Uint8Array([ + ...enc.encode(""), + ...payload, + ...enc.encode(""), + ]); + expect(scure.verify(wrapped, signature, signer.publicKey)).toBe(true); + }); + + it("32-byte key: treated as a mini-secret seed", () => { + const seed = new Uint8Array(32).fill(7); + const signer = slotSignerFromSecret(seed); + expect(ss58Encode(signer.publicKey)).toBe( + "5EsNLFaGe9XK5LzWH3i6eC2Wqv6YqZS1442N1C4yeSdP6uxy", + ); + }); + + it("rejects unexpected key lengths", () => { + expect(() => slotSignerFromSecret(new Uint8Array(33))).toThrow(/33/); + }); +}); + +describe("readCachedBulletinSlotSigner", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "playground-slot-signer-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function writeCache(content: unknown) { + writeFileSync( + join(dir, "dot-cli_AllowanceKeys.json"), + typeof content === "string" ? content : JSON.stringify(content), + ); + } + + it("derives the normalized signer from a cached 64-byte key", async () => { + writeCache({ + version: 1, + entries: { + BulletInAllowance: { + tag: "BulletInAllowance", + slotAccountKey: toHex(syntheticKey64()), + }, + }, + }); + const signer = await readCachedBulletinSlotSigner(dir); + expect(signer).not.toBeNull(); + expect(ss58Encode(signer!.publicKey)).toBe(NORMALIZED_SS58); + }); + + it("returns null when the cache file is missing", async () => { + await expect(readCachedBulletinSlotSigner(dir)).resolves.toBeNull(); + }); + + it("returns null when the cache has no BulletInAllowance entry", async () => { + writeCache({ version: 1, entries: {} }); + await expect(readCachedBulletinSlotSigner(dir)).resolves.toBeNull(); + }); + + it("returns null on corrupt content instead of throwing", async () => { + writeCache("not json"); + await expect(readCachedBulletinSlotSigner(dir)).resolves.toBeNull(); + }); +}); diff --git a/src/utils/allowances/slotSigner.ts b/src/utils/allowances/slotSigner.ts new file mode 100644 index 0000000..40f78e2 --- /dev/null +++ b/src/utils/allowances/slotSigner.ts @@ -0,0 +1,118 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Slot-account signer with the CORRECT public-key derivation for keys issued + * by the mobile app. + * + * Why this exists: the mobile returns `slotAccountKey` as 64 bytes of + * schnorrkel `SecretKey::to_bytes()` material ("32-byte sr25519 private key + * concatenated with 32-byte nonce", polkadot-app-android-v2's + * `SlotAccountKey.kt`), and grants the on-chain allowance to the AccountId it + * derives natively from those bytes (`RealAccountsProtocol.kt` → + * `claim_long_term_storage`). `@scure/sr25519` expects the ed25519-expanded + * scalar form (`to_ed25519_bytes()`, scalar ×8), so deriving a public key + * from the raw bytes yields a DIFFERENT address that the chain has never + * granted anything to. `@parity/product-sdk-terminal/host`'s + * `createSlotAccountSigner` has exactly that bug; until it is fixed upstream, + * every signer built from the allowance cache must come from here instead. + * Verified live on paseo-next-v2: the phone-issued grant sits on the + * normalized address, the raw-derived address has no on-chain footprint. + * Mirrors bulletin-deploy's `storage-signer.ts` (shipped in 0.8.3). + */ + +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { fromHex } from "polkadot-api/utils"; +import { getPolkadotSigner } from "polkadot-api/signer"; +import type { PolkadotSigner } from "polkadot-api"; +import * as sr25519 from "@scure/sr25519"; +import { DAPP_ID } from "../../config.js"; + +/** + * Convert a schnorrkel `SecretKey::to_bytes()` scalar (canonical form) to the + * `to_ed25519_bytes()` form `@scure/sr25519` expects: multiply the scalar + * half (bytes 0-31, little-endian) by the cofactor 8. The nonce half (bytes + * 32-63) is unchanged. 32-byte mini-secrets pass through untouched. + */ +export function normalizeSchnorrkelScalar(key: Uint8Array): Uint8Array { + if (key.length !== 64) return key; + const out = new Uint8Array(key); + let carry = 0; + for (let i = 0; i < 32; i++) { + const v = key[i] * 8 + carry; + out[i] = v & 0xff; + carry = v >> 8; + } + return out; +} + +/** + * Build a `PolkadotSigner` from slot-key secret material. + * - 64 bytes: phone-issued schnorrkel `to_bytes()` form — normalized first. + * - 32 bytes: mini-secret seed — expanded via `secretFromSeed`. + */ +export function slotSignerFromSecret(secret: Uint8Array): PolkadotSigner { + let expanded: Uint8Array; + if (secret.length === 64) { + expanded = normalizeSchnorrkelScalar(secret); + } else if (secret.length === 32) { + expanded = sr25519.secretFromSeed(secret); + } else { + throw new Error( + `Bulletin slot key: unexpected length ${secret.length} (expected 32 or 64 bytes)`, + ); + } + const publicKey = sr25519.getPublicKey(expanded); + return getPolkadotSigner(publicKey, "Sr25519", async (data) => sr25519.sign(expanded, data)); +} + +// Mirrors product-sdk-terminal's sanitizeAppId (host.js) so the filename we +// read matches the one the SDK writes for any appId. +function sanitizeAppId(appId: string): string { + return appId.replace(/[^a-zA-Z0-9_.-]/g, "_"); +} + +/** + * Read the BulletInAllowance slot key from the product-sdk-terminal allowance + * cache (`/_AllowanceKeys.json`, v1 format — the same + * file `ensureSlotAccountSigner` writes) and derive the CORRECT signer from + * it. Returns null when the cache or entry is missing or unreadable — callers + * fall back to the SDK signer, which keeps behavior identical to today's on + * machines without a cached key. + * + * `storageDir`/`appId` must come from the SAME adapter that ran + * `ensureSlotAccountSigner` (it exposes both as readonly fields) — a + * mismatched namespace reads a stale or absent key and silently falls back + * to the SDK's wrong-address signer. + */ +export async function readCachedBulletinSlotSigner( + storageDir?: string, + appId: string = DAPP_ID, +): Promise { + try { + const path = join( + storageDir ?? join(homedir(), ".polkadot-apps"), + `${sanitizeAppId(appId)}_AllowanceKeys.json`, + ); + const cache = JSON.parse(await readFile(path, "utf-8")); + const hex = cache?.entries?.BulletInAllowance?.slotAccountKey; + if (typeof hex !== "string") return null; + return slotSignerFromSecret(fromHex(hex)); + } catch { + return null; + } +} diff --git a/src/utils/auth.connect.test.ts b/src/utils/auth.connect.test.ts index 7295bf7..a3e5585 100644 --- a/src/utils/auth.connect.test.ts +++ b/src/utils/auth.connect.test.ts @@ -48,7 +48,16 @@ vi.mock("@parity/product-sdk-terminal", async (importOriginal) => { }; }); -import { connect } from "./auth.js"; +// waitForLogin records the login stamp with its default storage dir; mock it +// so tests never write to the real ~/.polkadot-apps. +const { recordLoginStampMock } = vi.hoisted(() => ({ + recordLoginStampMock: vi.fn(async () => undefined), +})); +vi.mock("./loginStamp.js", () => ({ + recordLoginStamp: recordLoginStampMock, +})); + +import { connect, getSessionSigner, waitForLogin } from "./auth.js"; // A valid ristretto255 point — same frozen vector as `auth.test.ts`'s // deriveSessionAddresses block. `connect()` derives display addresses from @@ -119,3 +128,90 @@ describe("connect() adapter lifecycle", () => { } }); }); + +// A second valid ristretto255 point, distinct from TEST_ROOT_BYTES, so the +// two sessions derive different display addresses. Frozen output of +// `scure.getPublicKey(scure.secretFromSeed(new Uint8Array(32).fill(2)))`. +const OTHER_ROOT_BYTES = Uint8Array.from([ + 0x1a, 0x4f, 0xee, 0x48, 0xc1, 0xba, 0x1a, 0x48, 0xe8, 0xcd, 0x43, 0x78, 0x2a, 0x84, 0x85, 0xd6, + 0x35, 0xaa, 0x91, 0xcf, 0xb8, 0x2c, 0xbb, 0x47, 0x7f, 0x0c, 0x1c, 0x57, 0x6b, 0xc4, 0x03, 0x1c, +]); + +describe("multi-session selection", () => { + beforeEach(() => { + createTerminalAdapterMock.mockReset(); + waitForSessionsMock.mockReset(); + recordLoginStampMock.mockClear(); + }); + + it("getSessionSigner uses the NEWEST session, not the oldest", async () => { + // The session repository APPENDS: after a re-pair the array holds + // [stale, fresh]. Requests sent on the stale session reach a channel + // the phone may no longer serve — the silent "nothing shows up on the + // phone" failure. Selection must always be the most recent pairing. + const stale = { id: "old", rootAccountId: TEST_ROOT_BYTES }; + const fresh = { id: "new", rootAccountId: OTHER_ROOT_BYTES }; + const adapter = fakeAdapter(); + createTerminalAdapterMock.mockReturnValue(adapter); + waitForSessionsMock.mockResolvedValue([stale, fresh]); + + const handle = await getSessionSigner(); + + expect(handle).not.toBeNull(); + expect(handle!.userSession).toBe(fresh); + handle!.destroy(); + }); + + it("waitForLogin reports the newest session and prunes stale ones", async () => { + const stale = { id: "old", rootAccountId: TEST_ROOT_BYTES }; + const fresh = { id: "new", rootAccountId: OTHER_ROOT_BYTES }; + const disconnect = vi.fn(async () => ({ isOk: () => true })); + const adapter = { + ...fakeAdapter(), + sessions: { disconnect }, + }; + waitForSessionsMock.mockResolvedValue([stale, fresh]); + + const statuses: Array<{ step: string; address?: string }> = []; + const authPromise = Promise.resolve({ + match: (ok: (s: unknown) => void) => ok(fresh), + }); + + const address = await waitForLogin({ adapter, authPromise } as any, (status) => + statuses.push(status as { step: string; address?: string }), + ); + + // The reported identity is the JUST-PAIRED session's, never a stale one. + const success = statuses.find((s) => s.step === "success"); + expect(success?.address).toBe(address); + expect(address).toBeTruthy(); + + // Stale sessions are disconnected (best-effort) so they cannot be + // selected by later commands or accumulate across re-pairs. + expect(disconnect).toHaveBeenCalledTimes(1); + expect(disconnect).toHaveBeenCalledWith(stale); + expect(recordLoginStampMock).toHaveBeenCalledTimes(1); + }); + + it("waitForLogin survives a failing stale-session disconnect", async () => { + const stale = { id: "old", rootAccountId: TEST_ROOT_BYTES }; + const fresh = { id: "new", rootAccountId: OTHER_ROOT_BYTES }; + const disconnect = vi.fn(async () => { + throw new Error("remote unreachable"); + }); + const adapter = { ...fakeAdapter(), sessions: { disconnect } }; + waitForSessionsMock.mockResolvedValue([stale, fresh]); + + const statuses: Array<{ step: string }> = []; + const authPromise = Promise.resolve({ + match: (ok: (s: unknown) => void) => ok(fresh), + }); + + const address = await waitForLogin({ adapter, authPromise } as any, (s) => + statuses.push(s), + ); + + expect(address).toBeTruthy(); + expect(statuses.some((s) => s.step === "success")).toBe(true); + }); +}); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 26c620e..53fa2f3 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -44,6 +44,7 @@ import { TERMINAL_METADATA_URL, getChainConfig, } from "../config.js"; +import { recordLoginStamp } from "./loginStamp.js"; import { createPlaygroundSessionSigner, derivePlaygroundProductPublicKey, @@ -126,6 +127,21 @@ async function loadSessions(adapter: TerminalAdapter, timeoutMs?: number): Promi } } +/** + * Pick the session every flow should operate on: the MOST RECENT pairing. + * + * The SDK's session repository APPENDS (`ssoSessionRepository.add`), so after + * a re-pair the persisted list is `[stale, ..., fresh]`. The phone keeps a + * session map keyed by id and serves whichever sessions it still knows about, + * but a stale local entry may map to a channel the phone dropped — requests + * sent on it disappear without an error (the "scanned the QR but nothing + * shows on the phone" failure). `sessions[0]` selected exactly that stale + * entry. Callers must use this helper, never index the array directly. + */ +function newestSession(sessions: UserSession[]): UserSession { + return sessions[sessions.length - 1]; +} + function createPlaygroundSigner(session: UserSession): PolkadotSigner { return createPlaygroundSessionSigner(session, { productId: PLAYGROUND_PRODUCT_ID, @@ -212,7 +228,7 @@ export async function connect(): Promise { try { sessions = await loadSessions(adapter); if (sessions.length > 0) { - const addresses = deriveSessionAddresses(sessions[0]); + const addresses = deriveSessionAddresses(newestSession(sessions)); // The "existing" result carries plain address data only — the // adapter is not part of it, so this is the last place that can // release it. Leaking it keeps a statement-store WebSocket + @@ -325,8 +341,24 @@ export async function waitForLogin( if (authenticated) { const sessions = await loadSessions(adapter, 3000); if (sessions.length > 0) { - const addresses = deriveSessionAddresses(sessions[0]); + const addresses = deriveSessionAddresses(newestSession(sessions)); address = addresses.productAddress; + // Prune stale sessions left behind by earlier pairings. + // Best-effort: disconnect tells the phone to drop its side + // and filters the local repository, so later commands can + // never select a dead channel. A failed disconnect is fine — + // newestSession() keeps selection correct regardless. + for (const stale of sessions.slice(0, -1)) { + try { + await adapter.sessions.disconnect(stale); + } catch { + // Phone unreachable for the stale session — ignore. + } + } + // Best-effort, never throws: powers the stale-session warning + // in deploy's preflight (the SSS allowance has no on-chain + // query, so "when did we last pair" is the only signal). + void recordLoginStamp(); onStatus({ step: "success", address, addresses }); } else { onStatus({ @@ -409,7 +441,7 @@ export async function getSessionSigner(): Promise { return null; } - const session = sessions[0]; + const session = newestSession(sessions); const signer = createPlaygroundSigner(session); const addresses = deriveSessionAddresses(session); @@ -479,7 +511,7 @@ export async function findSession(): Promise { } return null; } - const session = sessions[0]; + const session = newestSession(sessions); const address = sessionLogoutAddress(session); return { adapter, address, session }; } diff --git a/src/utils/decentralize/run.test.ts b/src/utils/decentralize/run.test.ts index 1f07466..5c213fa 100644 --- a/src/utils/decentralize/run.test.ts +++ b/src/utils/decentralize/run.test.ts @@ -13,8 +13,51 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { describe, expect, it } from "vitest"; -import { describeDeployEvent } from "./run.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Heavy underlying pieces mocked — the orchestrator test only cares about +// which signer reaches the Bulletin storage layer. Same pattern as +// `../deploy/run.test.ts`. +const { runStorageDeployMock, mirrorSiteMock, ensureSlotAccountSignerMock } = vi.hoisted(() => ({ + // Explicit arg type so `mock.calls[0][0]` typechecks (an arg-less vi.fn + // infers Parameters = [] and indexing the empty tuple is a tsc error). + runStorageDeployMock: vi.fn< + (arg: unknown) => Promise<{ + domainName: string; + fullDomain: string; + cid: string; + ipfsCid: string; + }> + >(async () => ({ + domainName: "my-site", + fullDomain: "my-site.dot", + cid: "bafysite", + ipfsCid: "bafyipfs", + })), + mirrorSiteMock: vi.fn(async () => ({ + directory: "/tmp/playground-cli-test-mirror-does-not-exist", + uploadRoot: "/tmp/playground-cli-test-mirror-does-not-exist", + fileCount: 3, + })), + ensureSlotAccountSignerMock: vi.fn(), +})); + +vi.mock("../deploy/storage.js", () => ({ runStorageDeploy: runStorageDeployMock })); +vi.mock("./mirror.js", () => ({ mirrorSite: mirrorSiteMock })); +vi.mock("@parity/product-sdk-terminal/host", () => ({ + createSlotAccountSigner: vi.fn(), + ensureSlotAccountSigner: ensureSlotAccountSignerMock, + requestResourceAllocation: vi.fn(), +})); +// Hermeticity: the corrected-derivation path reads the REAL allowance cache +// from ~/.polkadot-apps; null forces the SDK-signer fallback so assertions +// stay deterministic on machines that have a cached key. +vi.mock("../allowances/slotSigner.js", () => ({ + readCachedBulletinSlotSigner: vi.fn(async () => null), +})); + +import type { ResolvedSigner } from "../signer.js"; +import { describeDeployEvent, runDecentralize } from "./run.js"; describe("describeDeployEvent", () => { it("renders chunk-progress as a human-readable upload line", () => { @@ -35,3 +78,76 @@ describe("describeDeployEvent", () => { expect(describeDeployEvent({ kind: "phase-start", phase: "storage" })).toBeNull(); }); }); + +describe("runDecentralize — Bulletin storage signer", () => { + const SLOT_PUBLIC_KEY = new Uint8Array(32).fill(7); + const slotSigner = { publicKey: SLOT_PUBLIC_KEY } as any; + + const sessionSigner: ResolvedSigner = { + signer: { + publicKey: new Uint8Array(32), + signTx: vi.fn(), + signBytes: vi.fn(), + } as any, + address: "5Fake", + source: "session", + userSession: {} as any, + adapter: {} as any, + addresses: { + rootAddress: "5Root", + productAddress: "5Fake", + productH160: "0xbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef", + }, + destroy: vi.fn(), + }; + + beforeEach(() => { + runStorageDeployMock.mockClear(); + mirrorSiteMock.mockClear(); + ensureSlotAccountSignerMock.mockReset(); + ensureSlotAccountSignerMock.mockResolvedValue(slotSigner); + }); + + it("phone mode threads the slot key as storageSigner — chunks never phone-sign", async () => { + await runDecentralize({ + siteUrl: "https://example.com", + label: "my-site", + fullDomain: "my-site.dot", + mode: "phone", + userSigner: sessionSigner, + env: "paseo-next-v2", + }); + + expect(runStorageDeployMock).toHaveBeenCalledTimes(1); + const arg = runStorageDeployMock.mock.calls[0][0] as unknown as { + auth: { + signerAddress?: string; + storageSigner?: unknown; + storageSignerAddress?: string; + }; + }; + // DotNS keeps the phone signer... + expect(arg.auth.signerAddress).toBe("5Fake"); + // ...but Bulletin storage signs with the local slot key. + expect(arg.auth.storageSigner).toBe(slotSigner); + expect(arg.auth.storageSignerAddress).toBeDefined(); + expect(arg.auth.storageSignerAddress).not.toBe("5Fake"); + }); + + it("dev mode passes no storageSigner and never touches the slot key", async () => { + await runDecentralize({ + siteUrl: "https://example.com", + label: "my-site", + fullDomain: "my-site.dot", + mode: "dev", + userSigner: null, + env: "paseo-next-v2", + }); + + const arg = runStorageDeployMock.mock.calls[0][0] as unknown as { + auth: { storageSigner?: unknown }; + }; + expect(arg.auth.storageSigner).toBeUndefined(); + expect(ensureSlotAccountSignerMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/decentralize/run.ts b/src/utils/decentralize/run.ts index a6cbb0b..5e16dee 100644 --- a/src/utils/decentralize/run.ts +++ b/src/utils/decentralize/run.ts @@ -31,7 +31,12 @@ import { rmSync } from "node:fs"; import { getChainConfig, type Env } from "../../config.js"; import { publishToPlayground } from "../deploy/playground.js"; import type { DeployLogEvent } from "../deploy/progress.js"; -import { type DeployApproval, resolveSignerSetup, type SignerMode } from "../deploy/signerMode.js"; +import { + type DeployApproval, + resolveSignerSetup, + resolveStorageSignerOptions, + type SignerMode, +} from "../deploy/signerMode.js"; import { createSigningCounter, type SigningCounter, @@ -168,6 +173,11 @@ export async function runDecentralize( directory: mirror.uploadRoot, }); + // Bulletin storage chunks must sign with the local BulletInAllowance + // slot key, never the phone signer — chunk txs blow the phone's + // statement-store message cap. See resolveStorageSignerOptions. + const storageSignerOptions = await resolveStorageSignerOptions(mode, userSigner); + onEvent?.({ kind: "storage-start", fullDomain }); const result = await runStorageDeploy({ // Upload from the resolved index.html parent, NOT from @@ -177,12 +187,15 @@ export async function runDecentralize( // Wrap the DotNS auth signer so each phone tap surfaces a // "check your phone" lifecycle event. No-op in dev mode (auth // has no signer — bulletin-deploy uses its default mnemonic). - auth: wrapAuthForSigning( - setup.bulletinDeployAuthOptions, - setup.approvals, - counter, - emitSigning, - ), + auth: { + ...wrapAuthForSigning( + setup.bulletinDeployAuthOptions, + setup.approvals, + counter, + emitSigning, + ), + ...storageSignerOptions, + }, env, onLogEvent: (event) => onEvent?.({ kind: "storage-event", event }), }); diff --git a/src/utils/deploy/run.test.ts b/src/utils/deploy/run.test.ts index c7f77d4..669e423 100644 --- a/src/utils/deploy/run.test.ts +++ b/src/utils/deploy/run.test.ts @@ -78,6 +78,26 @@ vi.mock("../../telemetry.js", () => ({ errorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)), })); +// Boundary mock for the BulletInAllowance slot key. Phone-mode deploys +// resolve it as the Bulletin STORAGE signer (chunk txs are too large for the +// phone's statement-store channel). The slot public key differs from the +// session signer's so assertions can prove which key was threaded through. +const SLOT_PUBLIC_KEY = new Uint8Array(32).fill(7); +const slotSigner = { publicKey: SLOT_PUBLIC_KEY } as any; +const { getBulletinAllowanceSignerMock, createStorageQuotaContextMock, quotaDestroyMock } = + vi.hoisted(() => ({ + getBulletinAllowanceSignerMock: vi.fn(), + createStorageQuotaContextMock: vi.fn(), + quotaDestroyMock: vi.fn(), + })); +vi.mock("../allowances/bulletin.js", () => ({ + getBulletinAllowanceSigner: getBulletinAllowanceSignerMock, +})); +vi.mock("./storageQuota.js", () => ({ + createStorageQuotaContext: createStorageQuotaContextMock, +})); +const quotaApi = { marker: "bulletin-api" } as any; + import { runDeploy, type DeployEvent } from "./run.js"; import type { ResolvedSigner } from "../signer.js"; @@ -89,6 +109,9 @@ const fakeUserSigner: ResolvedSigner = { }, address: "5Fake", source: "session", + // Host wiring consumed by resolveStorageSignerOptions in phone mode. + userSession: {} as any, + adapter: {} as any, // `addresses` is forwarded from SessionHandle in real code. The // claimed-owner flow reads `addresses.productH160` — without it, // dev-mode publish would silently fall through to "no claimed @@ -111,6 +134,15 @@ beforeEach(() => { publishToPlaygroundMock.mockClear(); runBuildMock.mockClear(); withSpanMock.mockClear(); + getBulletinAllowanceSignerMock.mockReset(); + getBulletinAllowanceSignerMock.mockResolvedValue(slotSigner); + createStorageQuotaContextMock.mockReset(); + quotaDestroyMock.mockClear(); + createStorageQuotaContextMock.mockReturnValue({ + bulletinApi: quotaApi, + requiredBytes: 1234, + destroy: quotaDestroyMock, + }); }); describe("runDeploy", () => { @@ -138,6 +170,10 @@ describe("runDeploy", () => { const arg = runStorageDeploy.mock.calls[0][0]; expect(arg.auth).toEqual({}); expect(arg.domainName).toBe("my-app"); + + // Dev mode never opens a Bulletin client for quota checks — no slot + // signer is used, so there is nothing to check. + expect(createStorageQuotaContextMock).not.toHaveBeenCalled(); }); it("dev mode with playground: ZERO planned approvals AND user H160 is claimed as owner", async () => { @@ -232,6 +268,27 @@ describe("runDeploy", () => { expect(arg.auth.signerAddress).toBe("5Fake"); expect(arg.auth.signer).toBeDefined(); + // Bulletin STORAGE must sign with the local slot key, never the phone + // signer: chunk txs carry up to 2 MiB of callData and the phone's + // statement-store channel rejects them as "message too big" before + // the phone is even contacted. storageSigner takes precedence over + // signer for storage routing inside bulletin-deploy 0.8.3+. + expect(arg.auth.storageSigner).toBe(slotSigner); + expect(arg.auth.storageSignerAddress).toBeDefined(); + expect(arg.auth.storageSignerAddress).not.toBe("5Fake"); + + // The quota context flows into the allowance resolution so an + // undersized slot grant triggers the Increase flow BEFORE the upload + // starts (mid-upload Payment failures never fall back to the pool), + // and the dedicated WS client is always torn down. + expect(createStorageQuotaContextMock).toHaveBeenCalledWith(undefined, "/tmp/proj/dist"); + expect(getBulletinAllowanceSignerMock).toHaveBeenCalledWith({ + publishSigner: fakeUserSigner, + bulletinApi: quotaApi, + requiredBytes: 1234, + }); + expect(quotaDestroyMock).toHaveBeenCalledTimes(1); + const plan = events.find((e) => e.kind === "plan"); if (plan?.kind === "plan") { expect(plan.approvals.map((a) => a.phase)).toEqual([ diff --git a/src/utils/deploy/run.ts b/src/utils/deploy/run.ts index f32dca6..29ffdbd 100644 --- a/src/utils/deploy/run.ts +++ b/src/utils/deploy/run.ts @@ -24,7 +24,12 @@ 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 { + resolveSignerSetup, + resolveStorageSignerOptions, + type SignerMode, + type DeployApproval, +} from "./signerMode.js"; import { wrapSignerWithEvents, createSigningCounter, @@ -32,6 +37,7 @@ import { type SigningEvent, } from "./signingProxy.js"; import type { DeployLogEvent } from "./progress.js"; +import { createStorageQuotaContext } from "./storageQuota.js"; import { withDeployPhase } from "./phase.js"; import type { ResolvedSigner } from "../signer.js"; import type { Env } from "../../config.js"; @@ -139,6 +145,40 @@ export async function runDeploy(options: RunDeployOptions): Promise>; + try { + storageSignerOptions = await resolveStorageSignerOptions( + options.mode, + options.userSigner, + quota + ? { + ...quota, + onWarning: (message) => + options.onEvent({ + kind: "storage-event", + event: { kind: "info", message }, + }), + } + : undefined, + ); + } finally { + quota?.destroy(); + } return await withDeployPhase( "storage-and-dotns", "cli.deploy.storage-dotns", @@ -149,7 +189,7 @@ export async function runDeploy(options: RunDeployOptions): Promise options.onEvent({ kind: "storage-event", event }), env: options.env, }); diff --git a/src/utils/deploy/signerMode.test.ts b/src/utils/deploy/signerMode.test.ts index 2bf42d8..b68ccad 100644 --- a/src/utils/deploy/signerMode.test.ts +++ b/src/utils/deploy/signerMode.test.ts @@ -13,8 +13,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { describe, it, expect } from "vitest"; -import { resolveSignerSetup } from "./signerMode.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Boundary mock: resolveStorageSignerOptions delegates slot resolution to +// allowances/bulletin.ts (single source for allocation, corrected derivation, +// and the quota/Increase flow). signerMode only owns the mode matrix. +const { getBulletinAllowanceSignerMock } = vi.hoisted(() => ({ + getBulletinAllowanceSignerMock: vi.fn(), +})); + +vi.mock("../allowances/bulletin.js", () => ({ + getBulletinAllowanceSigner: getBulletinAllowanceSignerMock, +})); + +import { ss58Encode } from "@parity/product-sdk-address"; +import { resolveSignerSetup, resolveStorageSignerOptions } from "./signerMode.js"; import type { ResolvedSigner } from "../signer.js"; function fakeSigner( @@ -176,3 +189,143 @@ describe("resolveSignerSetup — phone mode", () => { expect(result.publishSigner).toBe(user); }); }); + +describe("resolveStorageSignerOptions", () => { + // The slot key's public key deliberately differs from the session + // signer's so the address assertions can prove which key was chosen. + const SLOT_PUBLIC_KEY = new Uint8Array(32).fill(7); + const SLOT_SIGNER = { publicKey: SLOT_PUBLIC_KEY } as any; + + function sessionSignerWithHost(): ResolvedSigner { + return { + ...fakeSigner("session", "5UserPhone"), + userSession: {} as any, + adapter: {} as any, + }; + } + + beforeEach(() => { + getBulletinAllowanceSignerMock.mockReset(); + }); + + it("phone mode with a session resolves the Bulletin slot key as the storage signer", async () => { + // Bulletin chunk txs carry up to 2 MiB of callData. The statement-store + // session to the phone caps request messages far below that, so the + // storage signer MUST be a local key — the BulletInAllowance slot + // account — never the phone session signer. + getBulletinAllowanceSignerMock.mockResolvedValue(SLOT_SIGNER); + const user = sessionSignerWithHost(); + + const result = await resolveStorageSignerOptions("phone", user); + + expect(result.storageSigner).toBe(SLOT_SIGNER); + expect(result.storageSignerAddress).toBe(ss58Encode(SLOT_PUBLIC_KEY)); + expect(result.storageSignerAddress).not.toBe(user.address); + expect(getBulletinAllowanceSignerMock).toHaveBeenCalledWith({ + publishSigner: user, + bulletinApi: undefined, + requiredBytes: undefined, + }); + }); + + it("forwards the quota context so an undersized allowance triggers the Increase flow", async () => { + getBulletinAllowanceSignerMock.mockResolvedValue(SLOT_SIGNER); + const user = sessionSignerWithHost(); + const bulletinApi = { marker: true } as any; + + await resolveStorageSignerOptions("phone", user, { + bulletinApi, + requiredBytes: 14_000_000, + }); + + expect(getBulletinAllowanceSignerMock).toHaveBeenCalledWith({ + publishSigner: user, + bulletinApi, + requiredBytes: 14_000_000, + }); + }); + + it("dev mode never touches the slot key — no phone prompt in dev mode", async () => { + const user = sessionSignerWithHost(); + await expect(resolveStorageSignerOptions("dev", user)).resolves.toEqual({}); + expect(getBulletinAllowanceSignerMock).not.toHaveBeenCalled(); + }); + + it("phone mode with a --suri dev signer returns {} (local key, no size hazard)", async () => { + await expect(resolveStorageSignerOptions("phone", fakeSigner("dev"))).resolves.toEqual({}); + expect(getBulletinAllowanceSignerMock).not.toHaveBeenCalled(); + }); + + it("phone mode with no signer returns {}", async () => { + await expect(resolveStorageSignerOptions("phone", null)).resolves.toEqual({}); + }); + + it("slot resolution failure surfaces an actionable error, not a cryptic chunk failure", async () => { + // Without the slot key, bulletin-deploy would route 2 MiB chunk txs to + // the phone and every one would die with "message too big" after + // retries. Fail fast with a fix-it hint instead. + getBulletinAllowanceSignerMock.mockRejectedValue(new Error("user declined")); + await expect(resolveStorageSignerOptions("phone", sessionSignerWithHost())).rejects.toThrow( + /playground init/, + ); + }); + + it("quota shortfall downgrades to warn-and-proceed: slot signer still used", async () => { + // Whether the chain actually enforces the authorization extent at + // store() time is unconfirmed (upstream guidance: "the authorization + // is what counts"). Blocking a deploy on possibly-decorative numbers + // would be worse than letting bulletin-deploy report per-chunk truth, + // so a quota failure retries WITHOUT the quota check and proceeds. + const SLOT_PUBLIC_KEY2 = new Uint8Array(32).fill(8); + const fallbackSigner = { publicKey: SLOT_PUBLIC_KEY2 } as any; + getBulletinAllowanceSignerMock + .mockRejectedValueOnce( + new Error( + "Bulletin allowance for 5Slot is live but does not have enough quota. Re-run `playground init` and approve on your phone.", + ), + ) + .mockResolvedValueOnce(fallbackSigner); + const warnings: string[] = []; + const user = sessionSignerWithHost(); + + const result = await resolveStorageSignerOptions("phone", user, { + bulletinApi: { marker: true } as any, + requiredBytes: 14_000_000, + onWarning: (msg) => warnings.push(msg), + }); + + expect(result.storageSigner).toBe(fallbackSigner); + expect(result.storageSignerAddress).toBe(ss58Encode(SLOT_PUBLIC_KEY2)); + // Second call drops the quota context (no bulletinApi). + expect(getBulletinAllowanceSignerMock).toHaveBeenNthCalledWith(2, { + publishSigner: user, + bulletinApi: undefined, + requiredBytes: undefined, + }); + expect(warnings.join(" ")).toMatch(/quota/i); + }); + + it("quota shortfall without a fallback signer still fails with the actionable error", async () => { + getBulletinAllowanceSignerMock + .mockRejectedValueOnce(new Error("does not have enough quota")) + .mockRejectedValueOnce(new Error("user declined")); + await expect( + resolveStorageSignerOptions("phone", sessionSignerWithHost(), { + bulletinApi: {} as any, + requiredBytes: 1, + }), + ).rejects.toThrow(/playground init/); + }); + + it("session missing host wiring throws the init hint", async () => { + // requireSession inside getBulletinAllowanceSigner fires here; the + // wrap keeps the message actionable either way. + getBulletinAllowanceSignerMock.mockRejectedValue( + new Error( + 'No Bulletin allowance account available. Run "playground init" to grant allowances.', + ), + ); + const user = fakeSigner("session"); // no userSession / adapter + await expect(resolveStorageSignerOptions("phone", user)).rejects.toThrow(/playground init/); + }); +}); diff --git a/src/utils/deploy/signerMode.ts b/src/utils/deploy/signerMode.ts index 972dd43..67ece52 100644 --- a/src/utils/deploy/signerMode.ts +++ b/src/utils/deploy/signerMode.ts @@ -30,15 +30,18 @@ * phone. With no session, `claimedOwnerH160 = null` and the contract * falls back to caller (dev account owns the app). * - Phone mode: bulletin-deploy uses the user's phone signer for DotNS - * (3 taps). Storage always uses the bulletin-deploy pool mnemonic. - * Playground publish uses the user's phone signer (1 more tap). - * `claimedOwnerH160 = null` — the contract defaults to caller, which - * is the user's H160 anyway. + * (3 taps). Storage uses the BulletInAllowance slot key resolved by + * `resolveStorageSignerOptions` — NEVER the phone signer (see that + * function's doc for why). Playground publish uses the user's phone + * signer (1 more tap). `claimedOwnerH160 = null` — the contract + * defaults to caller, which is the user's H160 anyway. */ import { DEFAULT_MNEMONIC, type DeployOptions } from "bulletin-deploy"; import { ss58Encode } from "@parity/product-sdk-address"; +import type { CloudStorageApi } from "@parity/product-sdk-cloud-storage"; import { seedToAccount } from "@parity/product-sdk-keys"; +import { getBulletinAllowanceSigner } from "../allowances/bulletin.js"; import type { ResolvedSigner } from "../signer.js"; import type { DeployPlan } from "./availability.js"; @@ -243,3 +246,92 @@ export function resolveSignerSetup(opts: ResolveOptions): DeploySignerSetup { return { bulletinDeployAuthOptions, publishSigner, claimedOwnerH160, approvals }; } + +/** + * Resolve the signer for Bulletin STORAGE txs (the CAR chunk uploads) in + * phone mode: the local BulletInAllowance slot key, threaded to + * bulletin-deploy as `storageSigner` / `storageSignerAddress`. + * + * Why this exists: since bulletin-deploy 0.8.x, passing `signer` routes + * Bulletin storage through that signer too — not just DotNS. Chunk txs carry + * up to 2 MiB of callData, and the phone session signer forwards the FULL + * callData over the statement store (`session.createTransaction`), whose + * request-size cap is 4 KiB on the pinned host-papp 0.7.9 (254 KiB upstream, + * and the Android app itself caps statements at 256 KiB). A phone-signed + * chunk therefore fails client-side with "message too big" before the phone + * is ever contacted. bulletin-deploy's `storageSigner` takes precedence over + * `signer` for storage routing only, so DotNS keeps the phone signer while + * chunks sign locally. bulletin-deploy 0.8.3 can resolve the same slot key + * itself from the shared `dot-cli` allowance cache, but only as a best-effort + * side path — when it misses (fresh machine, declined grant) it silently + * falls back to phone-signing the chunks, so we resolve and pass the key + * explicitly and fail fast with an actionable message instead. + * + * Resolution is delegated to `allowances/bulletin.ts::getBulletinAllowanceSigner` + * — the single source for slot allocation (cache hit → silent; miss → one + * phone approval), the CORRECTED schnorrkel key derivation (see + * `allowances/slotSigner.ts` — the SDK's own derivation produces an address + * the chain never granted anything to), and the quota check + `Increase` + * flow when `quota` is provided. + * + * Pass `quota` ({ bulletinApi, requiredBytes }) when the upload size is + * known: the slot's on-chain extent is verified up front and an undersized + * allowance triggers a single `Increase` request on the phone, instead of + * the upload dying mid-flight with Payment errors (which do NOT fall back + * to the pool — only a first-connection failure does). + * + * Quota shortfall is WARN-AND-PROCEED, never a block: whether the chain + * enforces the authorization extent at `store()` time is unconfirmed + * (upstream guidance is "the authorization is what counts", i.e. existence + * and expiry). After the Increase attempt the resolution retries without + * the quota check, surfaces `quota.onWarning`, and the deploy continues + * with the slot signer — bulletin-deploy reports per-chunk truth if the + * extent does turn out to be enforced. Only a total resolution failure + * (no slot key at all, grant declined) aborts the deploy. + * + * Dev mode returns `{}`: storage signs with bulletin-deploy's mnemonic or + * the `--suri` key (both local, no size hazard) and must never prompt the + * phone. A `--suri` signer under `--signer phone` also returns `{}` for the + * same reason. + */ +export async function resolveStorageSignerOptions( + mode: SignerMode, + userSigner: ResolvedSigner | null, + quota?: { + bulletinApi?: CloudStorageApi; + requiredBytes?: number; + onWarning?: (message: string) => void; + }, +): Promise> { + if (mode !== "phone" || userSigner?.source !== "session") return {}; + + const resolve = async (withQuota: boolean) => { + const storageSigner = await getBulletinAllowanceSigner({ + publishSigner: userSigner, + bulletinApi: withQuota ? quota?.bulletinApi : undefined, + requiredBytes: withQuota ? quota?.requiredBytes : undefined, + }); + return { storageSigner, storageSignerAddress: ss58Encode(storageSigner.publicKey) }; + }; + + try { + return await resolve(true); + } catch (firstError) { + const firstMessage = firstError instanceof Error ? firstError.message : String(firstError); + try { + const fallback = await resolve(false); + quota?.onWarning?.( + `Bulletin storage quota check did not pass (${firstMessage}). ` + + "Continuing with the existing authorization — the upload will report " + + "per-chunk errors if the allowance really is exhausted.", + ); + return fallback; + } catch { + throw new Error( + `Could not resolve the Bulletin storage key for this session (${firstMessage}). ` + + "Storage uploads are too large to sign on the phone, so deploy cannot continue. " + + 'Re-run "playground init" and approve the Bulletin allowance on your phone.', + ); + } + } +} diff --git a/src/utils/deploy/storage.ts b/src/utils/deploy/storage.ts index 10500e8..16b28f3 100644 --- a/src/utils/deploy/storage.ts +++ b/src/utils/deploy/storage.ts @@ -51,9 +51,15 @@ export interface StorageDeployOptions { domainName: string | null; /** * Auth options forwarded to bulletin-deploy. Usually produced by - * `resolveSignerSetup()`. May be `{}` for the dev path. + * `resolveSignerSetup()` merged with `resolveStorageSignerOptions()`. + * May be `{}` for the dev path. `storageSigner` (the BulletInAllowance + * slot key) takes precedence over `signer` for Bulletin storage routing + * inside bulletin-deploy — chunk txs are too large for phone signing. */ - auth: Pick; + auth: Pick< + DeployOptions, + "signer" | "signerAddress" | "mnemonic" | "storageSigner" | "storageSignerAddress" + >; /** 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/deploy/storageQuota.test.ts b/src/utils/deploy/storageQuota.test.ts new file mode 100644 index 0000000..c037186 --- /dev/null +++ b/src/utils/deploy/storageQuota.test.ts @@ -0,0 +1,54 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { CAR_OVERHEAD_FACTOR, estimateUploadBytes } from "./storageQuota.js"; + +describe("estimateUploadBytes", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "playground-quota-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("sums nested file sizes and applies the CAR overhead factor", () => { + writeFileSync(join(dir, "index.html"), "x".repeat(1000)); + mkdirSync(join(dir, "assets")); + writeFileSync(join(dir, "assets", "app.js"), "y".repeat(2000)); + + expect(estimateUploadBytes(dir)).toBe(Math.ceil(3000 * CAR_OVERHEAD_FACTOR)); + }); + + it("returns 0 for an empty directory", () => { + expect(estimateUploadBytes(dir)).toBe(0); + }); + + it("returns null for a missing directory instead of throwing", () => { + expect(estimateUploadBytes(join(dir, "nope"))).toBeNull(); + }); + + it("estimates a single file when pointed at one", () => { + const file = join(dir, "bundle.bin"); + writeFileSync(file, "z".repeat(500)); + expect(estimateUploadBytes(file)).toBe(Math.ceil(500 * CAR_OVERHEAD_FACTOR)); + }); +}); diff --git a/src/utils/deploy/storageQuota.ts b/src/utils/deploy/storageQuota.ts new file mode 100644 index 0000000..9ba64e3 --- /dev/null +++ b/src/utils/deploy/storageQuota.ts @@ -0,0 +1,118 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Up-front quota context for the Bulletin storage upload. + * + * The slot account's on-chain allowance is finite (observed grants: + * 10 transactions / 4 MiB per claim) while an app's CAR can easily exceed + * that (each chunk is up to 2 MiB). Without a pre-flight check the upload + * dies mid-flight with Payment dispatch errors — and mid-upload failures do + * NOT fall back to the pool (only a failure on first connection does, see + * bulletin-deploy's `selectStorageReconnect`). This module supplies + * `resolveStorageSignerOptions` with the two inputs it needs to verify the + * extent up front and trigger the one-tap `Increase` flow when short: + * a size estimate and a short-lived Bulletin API handle. + * + * Everything here is best-effort: estimate or client-construction failures + * yield `null`, which downgrades the deploy to the no-quota-check behavior + * rather than blocking it. + */ + +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { createClient } from "polkadot-api"; +import { getWsProvider } from "polkadot-api/ws"; +import { paseo_bulletin as bulletin } from "@parity/product-sdk-descriptors/paseo-bulletin"; +import type { CloudStorageApi } from "@parity/product-sdk-cloud-storage"; +import { getChainConfig, type Env } from "../../config.js"; +import { BULLETIN_WS_HEARTBEAT_MS } from "../bulletinWs.js"; + +/** + * CAR encoding adds block headers, DAG-PB structure nodes, and the root + * manifest on top of the raw file bytes. 15% headroom comfortably covers the + * observed overhead while keeping the estimate conservative enough that a + * passing check cannot strand an upload just short of quota. + */ +export const CAR_OVERHEAD_FACTOR = 1.15; + +/** + * Estimate the Bulletin upload size for a build directory (or single file): + * recursive raw byte sum times {@link CAR_OVERHEAD_FACTOR}. Returns null when + * the path is unreadable — callers treat that as "skip the quota check". + * + * Symlinked entries are excluded (Dirent.isFile/isDirectory are false for + * symlinks, so the walk neither counts nor follows them — directory cycles + * are impossible). Build outputs don't normally contain symlinks; a small + * undercount is acceptable for a best-effort estimate. + */ +export function estimateUploadBytes(path: string): number | null { + try { + return Math.ceil(rawSize(path) * CAR_OVERHEAD_FACTOR); + } catch { + return null; + } +} + +function rawSize(path: string): number { + const stat = statSync(path); + if (stat.isFile()) return stat.size; + if (!stat.isDirectory()) return 0; + let total = 0; + for (const entry of readdirSync(path, { withFileTypes: true })) { + const child = join(path, entry.name); + if (entry.isFile()) total += statSync(child).size; + else if (entry.isDirectory()) total += rawSize(child); + } + return total; +} + +export interface StorageQuotaContext { + bulletinApi: CloudStorageApi; + requiredBytes: number; + /** Tears down the dedicated WS client. Always call from `finally`. */ + destroy(): void; +} + +/** + * Build the quota context for a phone-mode deploy: a size estimate plus a + * DEDICATED short-lived Bulletin client (same 300 s heartbeat rationale as + * the metadata upload in `playground.ts` — the shared client's 40 s default + * is too tight for Bulletin round-trips). Returns null when the estimate or + * client construction fails; the caller then proceeds without a quota check, + * which is exactly the pre-gate behavior. + */ +export function createStorageQuotaContext( + env: Env | undefined, + contentPath: string, +): StorageQuotaContext | null { + const requiredBytes = estimateUploadBytes(contentPath); + if (requiredBytes === null) return null; + try { + const cfg = getChainConfig(env); + const client = createClient( + getWsProvider([cfg.bulletinRpc, ...cfg.bulletinRpcFallbacks], { + heartbeatTimeout: BULLETIN_WS_HEARTBEAT_MS, + }), + ); + return { + bulletinApi: client.getTypedApi(bulletin) as unknown as CloudStorageApi, + requiredBytes, + destroy: () => client.destroy(), + }; + } catch { + return null; + } +} diff --git a/src/utils/loginStamp.test.ts b/src/utils/loginStamp.test.ts new file mode 100644 index 0000000..9309cd4 --- /dev/null +++ b/src/utils/loginStamp.test.ts @@ -0,0 +1,85 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { readLoginStampMs, recordLoginStamp, staleSessionWarning } from "./loginStamp.js"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +describe("staleSessionWarning", () => { + const now = 1_750_000_000_000; + + it("returns null when there is no stamp (old sessions: no heuristic, no noise)", () => { + expect(staleSessionWarning(null, now)).toBeNull(); + }); + + it("returns null for a fresh login", () => { + expect(staleSessionWarning(now - 60 * 60 * 1000, now)).toBeNull(); + }); + + it("returns null right up to the 2-day threshold", () => { + expect(staleSessionWarning(now - 2 * DAY_MS + 1000, now)).toBeNull(); + }); + + it("warns past 2 days with the logout/init remedy", () => { + const warning = staleSessionWarning(now - 2 * DAY_MS - 1000, now); + expect(warning).toMatch(/playground logout/); + expect(warning).toMatch(/playground init/); + expect(warning).toMatch(/2 days/); + }); + + it("returns null for a stamp in the future (clock skew — do not warn)", () => { + expect(staleSessionWarning(now + DAY_MS, now)).toBeNull(); + }); +}); + +describe("recordLoginStamp / readLoginStampMs", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "playground-login-stamp-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("round-trips the login timestamp", async () => { + await recordLoginStamp(1_750_000_000_000, dir); + await expect(readLoginStampMs(dir)).resolves.toBe(1_750_000_000_000); + }); + + it("returns null when no stamp exists", async () => { + await expect(readLoginStampMs(dir)).resolves.toBeNull(); + }); + + it("returns null on a corrupt stamp instead of throwing", async () => { + writeFileSync(join(dir, "dot-cli_LoginStamp.json"), "not json"); + await expect(readLoginStampMs(dir)).resolves.toBeNull(); + }); + + it("recordLoginStamp never throws, even when the directory is unwritable", async () => { + // Recording is best-effort telemetry for the staleness heuristic; it + // must never break the login flow. Use a regular FILE as the target + // "directory": mkdir/writeFile fail with ENOTDIR under any uid (a + // path under / would actually be writable when running as root). + const blocker = join(dir, "not-a-directory"); + writeFileSync(blocker, "occupied"); + await expect(recordLoginStamp(Date.now(), join(blocker, "child"))).resolves.toBeUndefined(); + }); +}); diff --git a/src/utils/loginStamp.ts b/src/utils/loginStamp.ts new file mode 100644 index 0000000..c6be40b --- /dev/null +++ b/src/utils/loginStamp.ts @@ -0,0 +1,90 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Local record of when the user last completed a QR login. + * + * Why: the statement-store (SSS) allowance that carries every phone + * interaction is a 1-day renewable resource with a ~2-day grace window, and + * there is NO on-chain query for it (confirmed by the bulletin-deploy + * investigation: the SSS account appears in zero storage keys even while + * working). The only reliable signal we can have is "when did the user last + * pair", so we stamp it ourselves at login and use it as a warn-only + * heuristic before phone-mode deploys. + * + * The stamp lives in the SDK storage dir under the `dot-cli_` prefix so + * `playground logout` (`clearLocalAppStorage`, which unlinks `${DAPP_ID}_*`) + * removes it together with the session it describes. Both I/O helpers are + * best-effort: a missing, corrupt, or unwritable stamp must never affect the + * login or deploy flows — the worst case is simply "no warning". + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { DAPP_ID } from "../config.js"; + +const STAMP_FILE = `${DAPP_ID}_LoginStamp.json`; + +/** ~2-3 days after login the SSS allowance is gone; warn from 2 days on. */ +const STALE_AFTER_MS = 2 * 24 * 60 * 60 * 1000; + +function stampPath(storageDir?: string): string { + return join(storageDir ?? join(homedir(), ".polkadot-apps"), STAMP_FILE); +} + +/** Best-effort write of the login moment. Never throws. */ +export async function recordLoginStamp(nowMs: number = Date.now(), storageDir?: string) { + try { + const path = stampPath(storageDir); + await mkdir(join(path, ".."), { recursive: true, mode: 0o700 }); + await writeFile(path, `${JSON.stringify({ lastLoginAt: nowMs })}\n`, { mode: 0o600 }); + } catch { + // The stamp only powers a warning heuristic — losing it is fine. + } +} + +/** Last recorded login time in epoch ms, or null when absent/corrupt. */ +export async function readLoginStampMs(storageDir?: string): Promise { + try { + const raw = await readFile(stampPath(storageDir), "utf-8"); + const parsed: unknown = JSON.parse(raw); + const value = (parsed as { lastLoginAt?: unknown })?.lastLoginAt; + return typeof value === "number" && Number.isFinite(value) ? value : null; + } catch { + return null; + } +} + +/** + * Warn-only staleness check. Returns the warning text when the last login is + * more than 2 days old, null otherwise. No stamp (pre-stamp sessions) and + * future stamps (clock skew) produce no warning — this heuristic must never + * block or scare users whose sessions still work; the SSS fast-fail in + * `sessionSigner.ts` is the authoritative runtime signal. + */ +export function staleSessionWarning(lastLoginAtMs: number | null, nowMs: number): string | null { + if (lastLoginAtMs === null) return null; + const age = nowMs - lastLoginAtMs; + if (age <= STALE_AFTER_MS) return null; + + const days = Math.floor(age / (24 * 60 * 60 * 1000)); + return ( + `warning: your phone session was paired ${days} days ago. Phone signing stops ` + + "working ~2 days after login (the statement-store allowance expires and cannot be " + + 'renewed remotely). If signing hangs or fails, run "playground logout" and then ' + + '"playground init" to pair again.' + ); +} diff --git a/src/utils/sessionSigner.test.ts b/src/utils/sessionSigner.test.ts index 3ed4a30..494d7a4 100644 --- a/src/utils/sessionSigner.test.ts +++ b/src/utils/sessionSigner.test.ts @@ -13,15 +13,18 @@ // 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"; +import type { PolkadotSigner } from "polkadot-api"; import { PLAYGROUND_PRODUCT_ID } from "../config.js"; import { INCOMPLETE_SESSION_MESSAGE, + SESSION_EXPIRED_MESSAGE, createPlaygroundSessionSigner, derivePlaygroundProductPublicKey, + wrapSignerWithSssFastFail, } from "./sessionSigner.js"; const DEV_PHRASE = "bottom drive obey lake curtain smoke basket hold race lonely fit walk"; @@ -110,3 +113,114 @@ describe("createPlaygroundSessionSigner", () => { expect(PLAYGROUND_PRODUCT_ID).toEqual("playground.dot"); }); }); + +describe("wrapSignerWithSssFastFail", () => { + // The statement-store adapter logs NoAllowanceError to console.error but + // does NOT reject the createTransaction promise — without intervention the + // phone-signing call hangs for the SDK's 180s queue timeout while the + // outer transaction watcher gives up with a useless message. The wrapper + // detects the log line and rejects within ~200ms with a fix-it message. + const NO_ALLOWANCE_LINE = + "submitRequest failed: NoAllowanceError: Submit failed, no allowance set for account"; + + function makeSigner(overrides: Partial): PolkadotSigner { + return { + publicKey: new Uint8Array(32).fill(1), + signTx: vi.fn(async () => new Uint8Array([1])), + signBytes: vi.fn(async () => new Uint8Array([2])), + ...overrides, + } as PolkadotSigner; + } + + test("rejects fast with the logout/init message when NoAllowanceError is logged", async () => { + const hanging = makeSigner({ + signTx: () => { + console.error(NO_ALLOWANCE_LINE); + return new Promise(() => {}); // never settles — the real failure mode + }, + }); + const wrapped = wrapSignerWithSssFastFail(hanging); + + const started = Date.now(); + await expect(wrapped.signTx(new Uint8Array(), {}, new Uint8Array(), 0)).rejects.toThrow( + SESSION_EXPIRED_MESSAGE, + ); + // Well under the SDK's 180s queue timeout / 90s watcher timeout. + expect(Date.now() - started).toBeLessThan(5_000); + expect(SESSION_EXPIRED_MESSAGE).toMatch(/playground logout/); + expect(SESSION_EXPIRED_MESSAGE).toMatch(/playground init/); + }); + + test("signBytes gets the same fast-fail (raw signing rides the same channel)", async () => { + const hanging = makeSigner({ + signBytes: () => { + console.error(NO_ALLOWANCE_LINE); + return new Promise(() => {}); + }, + }); + const wrapped = wrapSignerWithSssFastFail(hanging); + await expect(wrapped.signBytes(new Uint8Array())).rejects.toThrow(SESSION_EXPIRED_MESSAGE); + }); + + test("happy path passes the result through and restores console.error", async () => { + const original = console.error; + const inner = makeSigner({}); + const wrapped = wrapSignerWithSssFastFail(inner); + + const result = await wrapped.signTx(new Uint8Array(), {}, new Uint8Array(), 0); + + expect(result).toEqual(new Uint8Array([1])); + expect(console.error).toBe(original); + expect(wrapped.publicKey).toBe(inner.publicKey); + }); + + test("console.error is restored even after a fast-fail rejection", async () => { + const original = console.error; + const hanging = makeSigner({ + signTx: () => { + console.error(NO_ALLOWANCE_LINE); + return new Promise(() => {}); + }, + }); + const wrapped = wrapSignerWithSssFastFail(hanging); + await wrapped.signTx(new Uint8Array(), {}, new Uint8Array(), 0).catch(() => {}); + expect(console.error).toBe(original); + }); + + test("unrelated console.error lines are forwarded, not swallowed", async () => { + const seen: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => { + seen.push(args.map(String).join(" ")); + }; + try { + const inner = makeSigner({ + signTx: async () => { + console.error("some unrelated diagnostic"); + return new Uint8Array([1]); + }, + }); + const wrapped = wrapSignerWithSssFastFail(inner); + await wrapped.signTx(new Uint8Array(), {}, new Uint8Array(), 0); + expect(seen).toContain("some unrelated diagnostic"); + // And the nested interception restored OUR replacement, not the + // process original — interception must be re-entrant because the + // deploy pipeline (storage.ts) intercepts console too. + expect(seen.length).toBe(1); + } finally { + console.error = original; + } + }); + + test("underlying rejection is passed through unchanged", async () => { + const failing = makeSigner({ + signTx: async () => { + throw new Error("user declined on phone"); + }, + }); + const wrapped = wrapSignerWithSssFastFail(failing); + await expect(wrapped.signTx(new Uint8Array(), {}, new Uint8Array(), 0)).rejects.toThrow( + "user declined on phone", + ); + }); +}); diff --git a/src/utils/sessionSigner.ts b/src/utils/sessionSigner.ts index d2008cf..7e5fbfd 100644 --- a/src/utils/sessionSigner.ts +++ b/src/utils/sessionSigner.ts @@ -84,5 +84,88 @@ export function createPlaygroundSessionSigner( ref: Pick, ): PolkadotSigner { const publicKey = derivePlaygroundProductPublicKey(sessionRootPublicKey(session), ref); - return createSessionSignerForAccount(session, { ...ref, publicKey }); + return wrapSignerWithSssFastFail(createSessionSignerForAccount(session, { ...ref, publicKey })); +} + +export const SESSION_EXPIRED_MESSAGE = + "Phone session expired: the statement-store allowance lapses ~2-3 days after login " + + "and cannot be renewed remotely (renewal requests travel over the expired channel). " + + 'Run "playground logout" and then "playground init" to pair again.'; + +/** + * Fast-fail for expired statement-store (SSS) allowances. + * + * Phone signing rides the statement store: `session.createTransaction` / + * `session.signRaw` submit a statement on the People chain that the phone + * subscribes to. The SSS allowance is a 1-day renewable resource (plus a + * grace window, ~2-3 days total after login). When it lapses, the + * statement-store adapter logs `NoAllowanceError` to `console.error` but + * does NOT reject the promise — the signing call hangs for the SDK's 180s + * queue timeout while the outer transaction watcher gives up at 90s with a + * misleading "transaction watcher silent" error, times 3 retries. + * + * This wrapper intercepts `console.error` for the duration of each signing + * call, detects the NoAllowanceError line, and rejects within ~200ms with an + * actionable message. Renewal genuinely requires re-pairing: the + * `requestResourceAllocation` that would extend the allowance itself travels + * over SSS, and only the QR login flow has a direct WebSocket channel. + * Mirrors bulletin-deploy's vendored `sessionSigner.ts` fast-fail, which does + * not cover us because we inject our own signer. + * + * Re-entrancy: the deploy pipeline (`deploy/storage.ts::interceptConsoleLog`) + * also swaps `console.error`. We capture whatever `console.error` is at call + * time and restore exactly that in `finally`, so the interceptions nest. + * Overlapping signing calls cannot interleave restores in practice — + * host-papp serializes all session operations through a poolSize-1 queue. + * A matched line is suppressed (the thrown error IS the user-facing + * message); everything else is forwarded. + * + * On a fast-fail the underlying signing promise is intentionally abandoned + * (it never settles in this failure mode — that's the bug). If it ever does + * settle later, Promise.race has both arms handled, so no unhandled + * rejection escapes; any post-restore NoAllowanceError lines simply land on + * the regular console.error. + */ +export function wrapSignerWithSssFastFail(signer: PolkadotSigner): PolkadotSigner { + function wrap( + fn: (...args: Args) => Promise, + ): (...args: Args) => Promise { + return async (...args: Args): Promise => { + let sawNoAllowance = false; + const previousError = console.error; + console.error = (...errArgs: unknown[]) => { + const line = errArgs.map(String).join(" "); + if (line.includes("NoAllowanceError") || line.includes("no allowance set")) { + sawNoAllowance = true; + return; // suppressed — SESSION_EXPIRED_MESSAGE replaces the raw stack + } + previousError(...errArgs); + }; + + let poll: ReturnType | null = null; + try { + return await Promise.race([ + fn(...args), + new Promise((_, reject) => { + poll = setInterval(() => { + if (sawNoAllowance) reject(new Error(SESSION_EXPIRED_MESSAGE)); + }, 200); + }), + ]); + } finally { + // Both arms are settled or abandoned here: the interval must + // die (it would otherwise keep the event loop alive — see + // process-guard), and console.error must be restored to + // whatever interceptor was active when we started. + if (poll !== null) clearInterval(poll); + console.error = previousError; + } + }; + } + + return { + publicKey: signer.publicKey, + signTx: wrap(signer.signTx.bind(signer)), + signBytes: wrap(signer.signBytes.bind(signer)), + }; }