Back to News & Insights
JavaScript October 4, 2026 · 12 min read

Intercepting a Wallet from a Browser Extension (TxnLense, Part 1)

Every time you sign something, an application asked your wallet to do it through a single JavaScript...

Intercepting a Wallet from a Browser Extension (TxnLense, Part 1)

Every time you sign something, an application asked your wallet to do it through a single JavaScript call, and your wallet showed you its interpretation of that call. Sometimes the interpretation is good. Often it's a hex blob and a confirm button. I wrote about the standards effort to fix this at the source in The End of Blind Signing: ERC-7730, clear signing, wallets and dapps agreeing on a way to describe what a signature does.

TxnLense is the other approach: what can you do today, without waiting for any wallet or dapp to adopt anything? Sit between the two. Read the call as it goes by, decode it, and tell the user in plain language what they're about to authorize.

This is a two-part series. Part 1 is about how interception works in a Manifest V3 extension, and the long, instructive way I got it working, including how I tested a wallet-interception extension on a machine that couldn't run a wallet. Part 2 is about the part I trust least in hindsight: the moment the decoder confidently told me something dangerous looked safe.

The pipeline is four steps: Intercept: catch ethsendTransaction, ethsignTypedData, and ethsignTypedDatav4 calls as the page makes them. Decode: turn calldata into a function name and named arguments, and typed-data payloads (including Permit2) into something readable. Flag: run a small set of deterministic rules over the result: unlimited approvals, unlimited permits, high-value transfers, unrecognized functions, Permit2 signatures. Show: render all of it in the extension popup.

The design decision I care most about is what it doesn't do: it never blocks, alters, or delays the call. The patched function records what it saw and then hands the original arguments to the original wallet function, unmodified. Version 0.1 is informational only.

That's a deliberate choice. A third-party extension with the power to veto a signature is a much heavier thing to ask a user to trust than one that only reads, and a rules-based flagger will have false positives; a false positive that silently blocks a legitimate transaction is a worse failure than a warning the user can read and ignore. Observing is a smaller, more defensible promise, and the code makes it structurally obvious:

Everything hard about this project comes from a much less glamorous problem: getting your code to run in the right place.

When a wallet like MetaMask is installed, it injects an object at window.ethereum in the page. Every dapp talks to it through window.ethereum.request({ method, params }). That's the interface (it's specified in EIP-1193), and it's the seam TxnLense hooks: replace request with a wrapper that observes and then forwards.

The catch is that a browser extension's code can run in three different places, and they can't all see the same things:

| Where | Can see window.ethereum? | Can use chrome.runtime? | Good for | |---|---|---|---| | The page itself ("MAIN world") | Yes, it's the same JS realm the wallet writes into | No | Patching the provider | | Content script ("isolated world") | No, it shares the DOM but gets its own JavaScript globals | Yes | Relaying messages | | Service worker | No DOM at all | Yes, plus network access | Decoding, simulation, storage |

That middle row is the one that trips everyone. A content script and the page share the same DOM, but they get separate JavaScript worlds, so a variable the page defines is invisible to the content script even though both are "on the page." Isolation is a security feature: it stops a hostile page from reaching into an extension's variables. It's also why a content script's window.ethereum is undefined even when the wallet is sitting right there.

So the job splits along those lines. The provider has to be patched from the page's world, but only the isolated world and the service worker can talk to the extension. Two pieces of code, one message boundary between them:

window.postMessage is the only thing that crosses the page-to-isolated boundary, and chrome.runtime.sendMessage is the only thing that crosses to the service worker. Nothing else connects them. Once you see it as three rooms and two doors, the architecture stops being mysterious. It also tells you exactly what a bug in any one of the rooms will look like from the others: silence.

The version I started with put nearly everything in one content script. It patched window.ethereum, it called chrome.runtime.sendMessage to reach the worker, and to bridge worlds it built a element with inline code as text and appended it to the page, relaying events through a CustomEvent. The manifest declared that one file with "world": "MAIN", so it would run in the page's context.

If you read the table above, that plan has a contradiction in it: the file needs chrome.runtime (isolated world) and window.ethereum (main world) at the same time. No single world offers both. The design leaned on two different tricks to paper over that, an inline script and a manifest flag, and both of them turned out to be wrong for reasons I'd only find out by running it.

The first error wasn't even in the interception logic. Loading the project root as an unpacked extension failed with Could not load javascript 'content.js' for content script. The manifest at the root referred to files that existed only in the build output, dist/. Loading dist/ fixed it, but it left two copies of manifest.json in the project that could quietly drift apart, which is a problem I'd meet again in Part 2. The CSP wall

With the extension loaded, I opened the test page and triggered a transaction. Nothing happened: no popup update, no service-worker activity. But the extension's Errors panel had something:

That policy is Manifest V3's default: no inline script, only files from the extension itself. The culprit was the inline my content script built from a string. The fix for that was to delete the bridge, which turned out to be solving a problem that doesn't exist: postMessage already crosses between worlds natively, so a CustomEvent relay through injected inline code was never needed.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation