Watch-only accounts with offline signing #26

Merged
backmeupplz merged 1 commit from offline-signing into main 2026-10-08 23:42:53 +00:00
backmeupplz commented 2026-10-08 23:23:05 +00:00 (Migrated from github.com)

Closes #20.

What

  • Watch-only accounts: add just an address (first-run setup, or Manage accounts → Watch an address). Balances, Send and dapps work as usual; signing is done elsewhere.
  • Out and back: instead of signing, the wallet shows a request ({"plainwallet":"sign","from":…,"transaction"|"message"|"typedData":…}; transactions are the standard unsigned encoding with nonce and fees fixed). Copy it, or save it to a file in the browser extensions. Then paste or load what the other device signed.
  • Signing on the key side: Settings → Sign a request (also offered on the home screen when offline). It decodes the request from its bytes alone and fetches nothing. It shows site, account, network, recipient, value, max gas fee, nonce and calldata (or the message / hashed typed-data fields), then signs.
  • Dapps: eth_sendTransaction, personal_sign and eth_signTypedData_v4 all work. The approval window turns into the export/paste screen, and the site gets the hash or signature.
  • Closing in between: exported transactions are kept (storage.local.offline) and listed under Waiting for a signature (N) on the home screen until broadcast or discarded. Messages aren't kept, since only the waiting site could use them.
  • Offline mode: a notice on the home screen, no balance fetching, and clear errors for Send / Add token instead of "Failed to fetch".
  • Apps: Android/iOS/Mac (and Safari) are copy-only; only Chrome/Firefox save and load files.

Security notes (please review)

  • The response is accepted only if it is exactly the exported transaction (re-serialized byte for byte after removing the signature) and recovers to from, or, for messages, verifies as from's signature of exactly that message or typed data. The popup checks it for inline errors; for dapps the background checks it again before broadcasting or returning it.
  • The key side refuses already-signed transactions, transactions without a chain id, and types other than legacy, EIP-2930 and EIP-1559 (blob and 7702 carry more than the review shows). It also re-runs the EIP-4361 foreign-domain check when the request names a site.
  • The list of exported transactions is outside the MAC'd state, because adding a field to SIGNED would invalidate every existing MAC. On Firefox a website process could edit that list, but a broadcast still needs the other device's signature over exactly that entry. This is documented in Known gaps.
  • Watch-only accounts don't count toward Megapot tickets, and Export refuses them.
  • Known ceiling (ponytail: note): exporting a second transaction before broadcasting the first reuses the nonce, so the second fails to broadcast.

Checks

  • npm test (new test-offline.js: tx round-trips for legacy/1559/deploy; wrong signer, changed nonce or chain, unsigned, junk, 7702 and no-chain-id all refused; message and typed-data verification; account-dialog watch group and removal), tsc --noEmit, npm run build, wxt build -b firefox --mv3, npm run build:android.
  • End-to-end in Chromium for Testing (Playwright, two profiles, local anvil, Hardhat test keys only). Online profile with the watch-only account; key profile switched to offline mode:
    • Send → export → page closed → signed offline (request loaded from the saved file) → reopened → Waiting for a signature → bad paste rejected inline → broadcast → confirmed on chain.
    • Dapp: connect, personal_sign, eth_signTypedData_v4 (both signatures recover to the account), eth_sendTransaction (mined, removed from the waiting list).
    • Offline home notice, the offline Send error, and refusal of a request for an account the key side doesn't hold.
  • Not verified: the Android/iOS/Mac apps on a device, Safari, Firefox at runtime (built only), and the toolbar popup (tests used the full-tab view). Firefox may close the toolbar popup when the file picker opens; exported transactions survive that, and Open in tab avoids it.

🤖 Generated with Claude Code

Closes #20. ## What - **Watch-only accounts**: add just an address (first-run setup, or Manage accounts → Watch an address). Balances, Send and dapps work as usual; signing is done elsewhere. - **Out and back**: instead of signing, the wallet shows a request (`{"plainwallet":"sign","from":…,"transaction"|"message"|"typedData":…}`; transactions are the standard unsigned encoding with nonce and fees fixed). Copy it, or save it to a file in the browser extensions. Then paste or load what the other device signed. - **Signing on the key side**: Settings → *Sign a request* (also offered on the home screen when offline). It decodes the request from its bytes alone and fetches nothing. It shows site, account, network, recipient, value, max gas fee, nonce and calldata (or the message / hashed typed-data fields), then signs. - **Dapps**: `eth_sendTransaction`, `personal_sign` and `eth_signTypedData_v4` all work. The approval window turns into the export/paste screen, and the site gets the hash or signature. - **Closing in between**: exported transactions are kept (`storage.local.offline`) and listed under *Waiting for a signature (N)* on the home screen until broadcast or discarded. Messages aren't kept, since only the waiting site could use them. - **Offline mode**: a notice on the home screen, no balance fetching, and clear errors for Send / Add token instead of "Failed to fetch". - **Apps**: Android/iOS/Mac (and Safari) are copy-only; only Chrome/Firefox save and load files. ## Security notes (please review) - The response is accepted only if it is **exactly** the exported transaction (re-serialized byte for byte after removing the signature) and recovers to `from`, or, for messages, verifies as `from`'s signature of exactly that message or typed data. The popup checks it for inline errors; for dapps the background checks it **again** before broadcasting or returning it. - The key side refuses already-signed transactions, transactions without a chain id, and types other than legacy, EIP-2930 and EIP-1559 (blob and 7702 carry more than the review shows). It also re-runs the EIP-4361 foreign-domain check when the request names a site. - The list of exported transactions is outside the MAC'd state, because adding a field to `SIGNED` would invalidate every existing MAC. On Firefox a website process could edit that list, but a broadcast still needs the other device's signature over exactly that entry. This is documented in Known gaps. - Watch-only accounts don't count toward Megapot tickets, and Export refuses them. - Known ceiling (`ponytail:` note): exporting a second transaction before broadcasting the first reuses the nonce, so the second fails to broadcast. ## Checks - `npm test` (new `test-offline.js`: tx round-trips for legacy/1559/deploy; wrong signer, changed nonce or chain, unsigned, junk, 7702 and no-chain-id all refused; message and typed-data verification; account-dialog watch group and removal), `tsc --noEmit`, `npm run build`, `wxt build -b firefox --mv3`, `npm run build:android`. - End-to-end in Chromium for Testing (Playwright, two profiles, local anvil, Hardhat test keys only). Online profile with the watch-only account; key profile switched to offline mode: - Send → export → **page closed** → signed offline (request loaded from the saved file) → reopened → *Waiting for a signature* → bad paste rejected inline → broadcast → confirmed on chain. - Dapp: connect, `personal_sign`, `eth_signTypedData_v4` (both signatures recover to the account), `eth_sendTransaction` (mined, removed from the waiting list). - Offline home notice, the offline Send error, and refusal of a request for an account the key side doesn't hold. - **Not verified**: the Android/iOS/Mac apps on a device, Safari, Firefox at runtime (built only), and the toolbar popup (tests used the full-tab view). Firefox may close the toolbar popup when the file picker opens; exported transactions survive that, and *Open in tab* avoids it. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No description provided.