DEV Community

Aniket Misra
Aniket Misra

Posted on

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.

What it does, and what it deliberately doesn't

The pipeline is four steps:

  1. Intercept: catch eth_sendTransaction, eth_signTypedData, and eth_signTypedData_v4 calls as the page makes them.
  2. Decode: turn calldata into a function name and named arguments, and typed-data payloads (including Permit2) into something readable.
  3. Flag: run a small set of deterministic rules over the result: unlimited approvals, unlimited permits, high-value transfers, unrecognized functions, Permit2 signatures.
  4. 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:

return originalRequest(args)   // always. never blocks, never mutates.
Enter fullscreen mode Exit fullscreen mode

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

The fact that shapes the whole architecture

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:

   PAGE (main world)              CONTENT SCRIPT (isolated)         SERVICE WORKER
   ─────────────────              ─────────────────────────         ──────────────
   dapp calls request()
   patched wrapper ──postMessage──▶ content.js ──sendMessage──▶  decode → flag → simulate
                                        │                              │
                                  store for popup  ◀──────────── response ┘
Enter fullscreen mode Exit fullscreen mode

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 first version, and why it couldn't have worked

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 <script> 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.

Five failures, in order

1. Loading the wrong folder

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.

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:

Refused to execute inline script because it violates the following Content
Security Policy directive: "script-src 'self' 'wasm-unsafe-eval'".
Enter fullscreen mode Exit fullscreen mode

That policy is Manifest V3's default: no inline script, only files from the extension itself. The culprit was the inline <script> 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.

I made that change, reloaded, and the error disappeared. The extension still did nothing at all.

That was the first useful lesson: the absence of an error is not the absence of a bug. The CSP error had been the loud symptom, and clearing it just exposed the quiet one underneath.

3. Silence, and the decision to instrument

At this point I had a manifest with two scripts, one supposedly running in the page world (inject.js, declared "world": "MAIN") and one relay in the isolated world. No errors anywhere. I checked the popup, the service-worker console, the Errors panel, and the network tab. Nothing.

When you can't tell which of several components is failing, the fix isn't more staring. It's making silence impossible. I rewrote inject.js so it logged at every step: on load, on every poll attempt for window.ethereum, on success, on failure. Then I learned something that cost me a while: code running in the page's world logs to the page's console, not the extension's. I'd been checking every extension console and none of them would ever show it.

Opening DevTools on the test page itself finally showed something:

[txlens] inject.js: file is executing, top of file
[txlens] inject.js: starting to poll for window.ethereum
[txlens] inject.js: poll attempt 0, window.ethereum = undefined
[harness] script block starting
[harness] window.ethereum assigned successfully: {isMetaMask: true, …}
[txlens] inject.js: poll attempt 1, window.ethereum = undefined
…
[txlens] inject.js: poll attempt 20, window.ethereum = undefined
[txlens] inject.js: gave up after 20 attempts, window.ethereum never appeared
Enter fullscreen mode Exit fullscreen mode

The script was running, and running exactly as written. The polling loop even survived the expected race: it started before the page's own script had set the provider, which is normal at document_start. But after the page logged that it had assigned window.ethereum, the extension kept reading undefined for the rest of its two-second window. Typing console.log(window.ethereum) by hand in the console printed the object.

Same name, same page, two different answers.

4. The context dropdown

DevTools has a small dropdown at the top of the Console panel, defaulting to top, that selects which JavaScript context your expressions run in. I'd never had a reason to open it. When I did, it listed top (the page) and, underneath, three separate entries labelled txlens.

I selected each one and ran window.ethereum.__probe = 'hello'. In top, it worked. In every txlens entry:

Uncaught TypeError: Cannot set properties of undefined (setting '__probe')
Enter fullscreen mode Exit fullscreen mode

That's proof. The extension's script wasn't in the page's world. It was in an isolated one, looking at a window that had never heard of the wallet. (I don't know exactly why there were three entries. Presumably one per script or context. It didn't matter: none of them could see the provider.)

5. The actual cause, and the wrong theory I had first

My first theory was that Chromium's MAIN-world support was flaky. When three JavaScript contexts disagree about a global and nothing throws, "browser bug" is a natural conclusion. It was wrong.

The real cause was the machine. I develop on Windows 8.1, where Chrome and Edge both stop at version 109. The manifest-level "world": "MAIN" option shipped in Chrome 111, and older versions silently ignore the key. No warning, no error, no partial support. My "page-world" script had simply been running as an ordinary isolated content script the whole time. That fits every observation: the script executed, the polls ran, the provider was invisible.

There's a symmetry in this that I find funny in hindsight. On my old browser, the original design failed because the flag was ignored. On a modern browser it would have failed differently: once "world": "MAIN" is honored, the file has no chrome.runtime, so the very first chrome.runtime.sendMessage would throw. Same design, opposite failure, same root mistake, which was asking one file to live in two worlds.

The fix: two files, and a <script> tag with a src

The version that works separates the two jobs completely.

inject.js runs in the page and does only what only the page can do: patch the provider and post a message. No chrome.* calls anywhere in it. (Excerpts here are trimmed for length.)

function interceptProvider() {
  const provider = window.ethereum
  if (!provider || (provider as any).__txlens_patched) return

  const originalRequest = provider.request.bind(provider)

  provider.request = async function (args) {
    const { method, params } = args
    if (PASSTHROUGH.has(method) || !INTERCEPT.has(method)) return originalRequest(args)

    const payload = {
      type: 'ETH_TX_INTERCEPTED',
      interceptType: method === 'eth_sendTransaction' ? 'eth_sendTransaction' : 'eth_signTypedData',
      id: generateId(),
      data: params,
      timestamp: Date.now(),
    }
    window.postMessage({ __txlens: true, payload }, '*')
    return originalRequest(args) // never blocks, never mutates
  }
  ;(provider as any).__txlens_patched = true
}
Enter fullscreen mode Exit fullscreen mode

content.js runs in the isolated world, where chrome.runtime lives. It has two jobs: get inject.js into the page, and relay what comes back (the return path, which stores the decoded result for the popup, is omitted here).

const script = document.createElement('script')
script.src = chrome.runtime.getURL('inject.js')
script.onload = () => script.remove()
;(document.head || document.documentElement).appendChild(script)

window.addEventListener('message', (event) => {
  if (event.source !== window || !event.data?.__txlens) return
  chrome.runtime.sendMessage(event.data.payload) // → service worker
})
Enter fullscreen mode Exit fullscreen mode

And the manifest stops declaring inject.js as a content script at all. Instead it makes it fetchable:

"content_scripts": [
  { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_start" }
],
"web_accessible_resources": [
  { "resources": ["inject.js"], "matches": ["<all_urls>"] }
]
Enter fullscreen mode Exit fullscreen mode

Why does this work when the inline version didn't? A <script src="chrome-extension://…/inject.js"> isn't inline code. It's an external file load, so the CSP has nothing to refuse. And because the page parses and runs that tag, the code lands in the page's real JavaScript world regardless of which Chrome version is running. It doesn't depend on "world": "MAIN", so it works on my Chrome 109 exactly as it does on a current release. That's the older technique, and it ended up being the more portable one.

Two costs come with it. The first is that inject.js loads asynchronously, so it can lose a race with the page. The polling handles a wallet that isn't there yet, but not a dapp that captured a reference to request before the patch landed. The second is that a web-accessible resource can be fetched by any page that matches, so a site can probe for it and learn that TxnLense is installed. Both go on Part 2's list of things this can't hide.

I reloaded, refreshed the test page, clicked a button, clicked the toolbar icon, and the popup showed a decoded approve call for the first time.

Testing an interceptor without a wallet

There was one more constraint worth writing about, because it changed how I tested. Current MetaMask releases require Chrome 123 or newer. My browser tops out at 109. I couldn't run the wallet I was building around.

The workaround came from looking at the seam again. TxnLense doesn't care which wallet is on the other side. It only cares that window.ethereum exists and has a request method. EIP-1193 is the entire contract. Which means anything that implements request is a wallet as far as the extension can tell. So I wrote one:

window.ethereum = {
  isMetaMask: true, // some detection logic checks for this
  selectedAddress: FAKE_ACCOUNT,
  chainId: '0x1',
  async request({ method, params }) {
    log('>> ethereum.request  method=' + method)
    switch (method) {
      case 'eth_requestAccounts': return [FAKE_ACCOUNT]
      case 'eth_sendTransaction': return '0x' + 'ab'.repeat(32)  // fake tx hash
      case 'eth_signTypedData_v4': return '0x' + 'cd'.repeat(65) // fake signature
      default: return null
    }
  },
  on() {}, removeListener() {},
}
Enter fullscreen mode Exit fullscreen mode

Around that mock is a single HTML page with five buttons, each firing one scenario at it:

  1. An approve(spender, uint256.max) call: the unlimited-approval case
  2. A Permit2 PermitSingle typed-data signature with the maximum amount
  3. A native transfer of 5 ETH
  4. Calldata with a selector nothing recognizes
  5. An ordinary 0.01 ETH transfer

The fifth one is a control, and it matters as much as the other four. A flagger that warns on everything isn't cautious, it's broken, and you can only see that if you also feed it something that should pass clean. The harness serves over http://localhost (python -m http.server) rather than as a local file, since extensions need an extra opt-in permission to run on file:// pages.

Two honest caveats about this approach. First: I wish I'd made each button label its own scenario in the popup and the log. I ended up with a list of decoded transactions and no way to tell which button produced which, and I had to work it out from timestamps. Second, and more important: the harness proves the pipeline, not wallet compatibility. A fake provider can't reproduce the quirks of a real one: multiple wallets fighting over window.ethereum, providers that freeze their own methods, injection timing that differs per wallet. I still owe TxnLense a real test on a modern browser with real wallets before I'd claim it works with them.

Three gotchas that each cost me an hour

"Extension context invalidated." After I edited a stylesheet and reloaded the extension, every click on the test page threw this error from content.js. Nothing was wrong with the code. Reloading an extension gives new pages a fresh extension context, but a tab that was already open keeps running the old content script, which now holds a dead connection to a service worker that no longer exists. Refresh the tab after every reload. It's a one-keystroke fix that looks like a catastrophic regression.

The popup won't open itself. I kept waiting for a popup to appear when I triggered a transaction. In Manifest V3 it never will: an extension's popup opens only when the user clicks its toolbar icon, and there's no API to force it. That has a design consequence. Everything the interceptor sees has to be stored so the popup can read it whenever it's opened, because the popup isn't there when the interesting thing happens. It also suggests an obvious next feature: a badge on the toolbar icon when something risky goes by, since a badge is the one thing an extension is allowed to do unprompted.

Logs live where the code lives. Page-world code logs to the page's console; isolated-world code logs to the page's console under the extension's context; the service worker has its own separate DevTools. I lost real time reading the wrong console. When something is silent, check that you're looking at the right room.

What Part 1 leaves out

At the end of all this, the pipe works. A call goes in through a fake wallet, crosses two boundaries, gets decoded in a service worker, and lands in a popup, on a browser old enough that half the modern extension platform doesn't exist.

But "the pipe works" and "the verdict is right" are different claims, and I'd only proven the first. The first decoded result I actually looked at was the unlimited approval, the single most famous wallet-drainer pattern there is, and the one scenario TxnLense exists to catch. The popup opened. It decoded the function correctly. It displayed the spender. It displayed the amount as MAX (unlimited).

And across the top, in reassuring type, it said: LOOKS SAFE.

That's Part 2.

Top comments (0)