test: drive the EIP-1193 dApp approval round trips in the browser harness #183

Closed
opened 2026-08-09 16:15:49 +02:00 by clawbot · 2 comments
Collaborator

Blocked on the Chrome harness landing:
#181

Why

PR #171 moved dApp approval decryption
and signing into the popup, so the user's plaintext password no longer crosses
the extension messaging boundary. It landed carrying an explicit "Chrome not
verified interactively" caveat, because at the time we believed no agent could
drive a browser. That belief was wrong - see
#173 (comment).

Items 2-5 of the definition of done on
#157 require live eth_sendTransaction,
personal_sign and eth_signTypedData_v4 round trips. Those are the highest
value checks on the whole milestone, because they exercise a wallet's signing
path, and they are currently the single largest unverified surface in 1.0.0.

This is also the only part of the extension where the content script, the inpage
provider, the background worker and the popup all have to work together. Unit
tests cover each side in isolation and none of them cover the seam.

Definition of done

  1. A local test page, served from the harness (never from a remote origin),
    loads the real inpage provider and speaks EIP-1193 to window.ethereum.
  2. Driven end to end through the real content script, the real background
    worker and the real approval popup, with the RPC stubbed by route
    interception:
    • eth_requestAccounts - the site approval prompt appears, approving
      returns the selected address
    • personal_sign - the approve-sign screen appears, the correct message
      and origin are displayed, entering the password produces a signature
    • eth_signTypedData_v4 - as above for typed data
    • eth_sendTransaction - the approve-tx screen appears with the correct
      to/value/data, and approving produces a signed transaction handed to the
      stubbed RPC
  3. Every signature produced is verified to recover to the expected address.
    A test that merely asserts "a signature came back" is worthless here; it
    would pass against a wrong key, a wrong message or a wrong chain. Recover
    and compare.
  4. Rejection paths are covered: rejecting each prompt returns an EIP-1193 user
    rejection error to the calling page rather than hanging or resolving.
  5. The password never appears in any message crossing the boundary. Assert
    this directly by observing the messages, so the regression that
    #157 fixed cannot silently return.
    This is the check that gives the fix a permanent floor instead of a
    one-time review.
  6. Demonstrated to fail, per the standing rule on this repo: show each new
    assertion failing against a deliberately broken variant (for example a
    signature recovered against the wrong address, and a build that puts the
    password back in the message payload), with the captured output in the PR.
    A green run is not evidence.

Notes

  • Report honestly on anything that turns out not to be drivable rather than
    weakening an assertion to make it pass. If some part of the flow cannot be
    reached from the harness, say which and why, and it stays on the human list.
  • This does NOT discharge a real dApp with real funds against mainnet. That
    remains a human pass before the 1.0.0 tag and should not be presented as
    covered.
  • Once this lands, post the result on
    #171 so the caveat recorded there is
    closed out explicitly rather than left dangling.
Blocked on the Chrome harness landing: https://git.eeqj.de/sneak/AutistMask/issues/181 ## Why PR https://git.eeqj.de/sneak/AutistMask/pulls/171 moved dApp approval decryption and signing into the popup, so the user's plaintext password no longer crosses the extension messaging boundary. It landed carrying an explicit "Chrome not verified interactively" caveat, because at the time we believed no agent could drive a browser. That belief was wrong - see https://git.eeqj.de/sneak/AutistMask/issues/173#issuecomment-49609. Items 2-5 of the definition of done on https://git.eeqj.de/sneak/AutistMask/issues/157 require live `eth_sendTransaction`, `personal_sign` and `eth_signTypedData_v4` round trips. Those are the highest value checks on the whole milestone, because they exercise a wallet's signing path, and they are currently the single largest unverified surface in 1.0.0. This is also the only part of the extension where the content script, the inpage provider, the background worker and the popup all have to work together. Unit tests cover each side in isolation and none of them cover the seam. ## Definition of done 1. A local test page, served from the harness (never from a remote origin), loads the real inpage provider and speaks EIP-1193 to `window.ethereum`. 2. Driven end to end through the real content script, the real background worker and the real approval popup, with the RPC stubbed by route interception: - `eth_requestAccounts` - the site approval prompt appears, approving returns the selected address - `personal_sign` - the approve-sign screen appears, the correct message and origin are displayed, entering the password produces a signature - `eth_signTypedData_v4` - as above for typed data - `eth_sendTransaction` - the approve-tx screen appears with the correct to/value/data, and approving produces a signed transaction handed to the stubbed RPC 3. **Every signature produced is verified to recover to the expected address.** A test that merely asserts "a signature came back" is worthless here; it would pass against a wrong key, a wrong message or a wrong chain. Recover and compare. 4. Rejection paths are covered: rejecting each prompt returns an EIP-1193 user rejection error to the calling page rather than hanging or resolving. 5. **The password never appears in any message crossing the boundary.** Assert this directly by observing the messages, so the regression that https://git.eeqj.de/sneak/AutistMask/issues/157 fixed cannot silently return. This is the check that gives the fix a permanent floor instead of a one-time review. 6. Demonstrated to fail, per the standing rule on this repo: show each new assertion failing against a deliberately broken variant (for example a signature recovered against the wrong address, and a build that puts the password back in the message payload), with the captured output in the PR. A green run is not evidence. ## Notes - Report honestly on anything that turns out not to be drivable rather than weakening an assertion to make it pass. If some part of the flow cannot be reached from the harness, say which and why, and it stays on the human list. - This does NOT discharge a real dApp with real funds against mainnet. That remains a human pass before the 1.0.0 tag and should not be presented as covered. - Once this lands, post the result on https://git.eeqj.de/sneak/AutistMask/pulls/171 so the caveat recorded there is closed out explicitly rather than left dangling.
clawbot added this to the 1.0.0 milestone 2026-08-09 16:15:49 +02:00
Author
Collaborator

Plan, before code:

  • The test page is served by the existing route handler in tests/e2e/network.js — a GET on a reserved-TLD origin fulfilled from a fixture string, so nothing leaves the container and --host-resolver-rules=MAP * ~NOTFOUND still holds. The real inpage provider arrives by the shipped manifest's MAIN-world content script, not by anything the fixture loads itself.
  • Driving is fire-and-forget from the page: the fixture parks each window.ethereum.request() promise under a key, the test then drives the real approval window that the background opens, then reads the settled value back. That is what makes it possible to observe the prompt while the call is still in flight.
  • eth_sendRawTransaction gets a stub that records the artifact and answers with its real hash (ethers refuses a mismatch), so the signed transaction handed to the RPC is available to the test verbatim.
  • Every signature is recovered independently in the test with verifyMessage/verifyTypedData/Transaction.from().from and compared against the address read out of extension storage. The background already verifies too; the test does not lean on that.
  • The password check observes the popup-to-background channel directly, by wrapping chrome.runtime.sendMessage in the approval window before Approve is clicked and requiring the resulting AUTISTMASK_SIGN_RESPONSE/AUTISTMASK_TX_RESPONSE record to be present — so it cannot pass by observing nothing.
  • Test-only unit. No file under src/ changes.
Plan, before code: - The test page is served by the existing route handler in `tests/e2e/network.js` — a `GET` on a reserved-TLD origin fulfilled from a fixture string, so nothing leaves the container and `--host-resolver-rules=MAP * ~NOTFOUND` still holds. The real inpage provider arrives by the shipped manifest's `MAIN`-world content script, not by anything the fixture loads itself. - Driving is fire-and-forget from the page: the fixture parks each `window.ethereum.request()` promise under a key, the test then drives the real approval window that the background opens, then reads the settled value back. That is what makes it possible to observe the prompt while the call is still in flight. - `eth_sendRawTransaction` gets a stub that records the artifact and answers with its real hash (ethers refuses a mismatch), so the signed transaction handed to the RPC is available to the test verbatim. - Every signature is recovered independently in the test with `verifyMessage`/`verifyTypedData`/`Transaction.from().from` and compared against the address read out of extension storage. The background already verifies too; the test does not lean on that. - The password check observes the popup-to-background channel directly, by wrapping `chrome.runtime.sendMessage` in the approval window before Approve is clicked and requiring the resulting `AUTISTMASK_SIGN_RESPONSE`/`AUTISTMASK_TX_RESPONSE` record to be present — so it cannot pass by observing nothing. - Test-only unit. No file under `src/` changes.
Author
Collaborator

Built and pushed: #273 (branch test/issue-183-dapp-round-trips, base next).

Ten tests, Chrome e2e suite 27 -> 37. All six definition-of-done items are covered; the full captured failing output for item 6 is in the PR body rather than duplicated here.

Verification: make check green (test-verify-build: 18 case(s) passed), make test-e2e 37/37 tests passed, both re-run after rebasing onto 52c7c1b, which brought in #216 and changed the transaction approval path these tests drive. Test-only: no file under src/ changes. Six deliberately broken variants — a corrupted recovered signer, the password put back in the payload, a provider that resolves instead of rejecting, and approval screens showing the wrong origin, message, value and call data — each made the relevant assertion fail, and were reverted.

Two things could not be driven, per the note on reporting honestly:

  • The browser-action popup itself. The site-connection prompt goes through chrome.action.openPopup(), and headless Chromium's browser-action popup is never a page Playwright can see — confirmed over 60s with the action URL already set. That one prompt is driven at the URL the extension puts on the action instead: same page, same approval id, same code path, but whether a real toolbar click shows it is not observable here. The sign and transaction prompts open real windows and needed none of this.
  • A real dApp with real funds against a real network. The RPC is stubbed throughout. Stated as an uncovered human pass in both README.md and TODO.md.

Two findings raised in the PR rather than fixed: the EIP-1193 code 4001 crosses the messaging boundary but does not survive src/content/inpage.js, which rebuilds the rejection as a bare Error, so the calling page catches a message and no code; and for a site connection the approve message races the popup teardown, which the disconnect handler settles as a rejection.

Built and pushed: https://git.eeqj.de/sneak/AutistMask/pulls/273 (branch `test/issue-183-dapp-round-trips`, base `next`). Ten tests, Chrome e2e suite 27 -> 37. All six definition-of-done items are covered; the full captured failing output for item 6 is in the PR body rather than duplicated here. Verification: `make check` green (`test-verify-build: 18 case(s) passed`), `make test-e2e` `37/37 tests passed`, both re-run after rebasing onto `52c7c1b`, which brought in https://git.eeqj.de/sneak/AutistMask/pulls/216 and changed the transaction approval path these tests drive. Test-only: no file under `src/` changes. Six deliberately broken variants — a corrupted recovered signer, the password put back in the payload, a provider that resolves instead of rejecting, and approval screens showing the wrong origin, message, value and call data — each made the relevant assertion fail, and were reverted. Two things could not be driven, per the note on reporting honestly: - The browser-action popup itself. The site-connection prompt goes through `chrome.action.openPopup()`, and headless Chromium's browser-action popup is never a page Playwright can see — confirmed over 60s with the action URL already set. That one prompt is driven at the URL the extension puts on the action instead: same page, same approval id, same code path, but whether a real toolbar click shows it is not observable here. The sign and transaction prompts open real windows and needed none of this. - A real dApp with real funds against a real network. The RPC is stubbed throughout. Stated as an uncovered human pass in both `README.md` and `TODO.md`. Two findings raised in the PR rather than fixed: the EIP-1193 code 4001 crosses the messaging boundary but does not survive `src/content/inpage.js`, which rebuilds the rejection as a bare `Error`, so the calling page catches a message and no `code`; and for a site connection the approve message races the popup teardown, which the disconnect handler settles as a rejection.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#183