Part 1 ended on the first transaction I ever ran through TxnLense end to end: an unlimited ERC-20 approval, the single most-cited pattern behind wallet-draining attacks, and the exact scenario the extension exists to catch. It decoded correctly. Function name, spender, amount: all correct, the amount displayed as MAX (unlimited).
The badge across the top said LOOKS SAFE.
This part is about why, what else I found sitting next to it once I started actually reading the code instead of trusting the pipeline I'd just finished verifying, and what a tool like this can and can't honestly claim once you've seen it fail once.
How a verdict gets made
Decoding and risk-flagging are separate stages on purpose. The decoder's only job is to turn a hex blob into structured data: function name, arguments, a list of "token flows" (who sends what to whom). The risk engine never looks at raw calldata. It only looks at what the decoder already produced:
// Rule 1 — unlimited token approval
const approvalFlow = partial.tokenFlows?.find(
f => f.symbol?.includes('approval') && f.amount === '∞',
)
if (approvalFlow) {
flags.push({
level: 'high',
label: 'Unlimited Approval',
reason: `You are granting unlimited ${approvalFlow.symbol.replace(' approval', '')} spending rights. …`,
})
}
Read that rule on its own and it's correct. If a flow exists whose symbol contains "approval" and whose amount is the infinity symbol, flag it high. The question the rule never asks, because it isn't its job to ask, is: did a flow get built at all?
Where the flow comes from
That's the decoder's job, in extractTokenFlows. Before my fix, the approve branch looked like this:
const tokenMeta = KNOWN_TOKENS[to?.toLowerCase()]
if (functionName === 'approve' && tokenMeta) {
const spenderParam = params.find(p => p.name === 'spender')
const amountParam = params.find(p => p.name === 'amount')
if (spenderParam && amountParam) {
flows.push({
/* … */
symbol: `${tokenMeta.symbol} approval`,
/* … */
})
}
}
KNOWN_TOKENS is a hardcoded map, a handful of well-known mainnet addresses to their symbol and decimals. tokenMeta is undefined for anything not on that list, and the && in the if means the entire block, the entire flow, is skipped when it is. Call approve on a token nobody's heard of and this function returns nothing, every time, no matter what the amount is.
Here's the test case I ran it against, from Part 1's mock wallet:
await window.ethereum.request({
method: 'eth_sendTransaction',
params: [{ to: FAKE_TOKEN, data: encodeApprove(FAKE_SPENDER, MAX_UINT256) }],
})
FAKE_TOKEN is a made-up address. It was never going to be in KNOWN_TOKENS. The decoder found nothing, tokenFlows came back empty, and the risk rule — correctly, by its own logic — had nothing to find.
Why "found nothing" and "couldn't look" are not the same answer
Rule 1 is written as if an empty result always means "no unlimited approval here." The allowlist gate means an empty result can also mean "I never checked, because I didn't recognize the token." The function returns the same shape either way, so nothing downstream can tell the two apart. The badge collapses them into one label: safe.
That gap is a bigger deal here than it would be in most software, because of who's likely to be on the other side of it. The hardcoded list covers tokens everyone already knows to trust. The dangerous case, almost by construction, is the unfamiliar one: an attacker's own token, or a brand-new deployment nobody's added to any list yet. The allowlist was most likely to fail exactly where the tool mattered most.
The fix, and what I had to check twice
The fix removes the gate and falls back to a generic label when the token isn't recognized:
const tokenMeta = KNOWN_TOKENS[to?.toLowerCase()] ?? {
symbol: 'Token',
decimals: 18,
name: 'Unknown Token',
}
if (functionName === 'approve') {
// … builds the flow unconditionally now
}
The amount and the spender were always fully decoded; only the pretty symbol was ever in question. Displaying "Token approval" instead of "USDC approval" costs nothing. Returning no flag at all for a genuinely unlimited approval cost everything the feature was built for.
I didn't trust that reasoning on its own, so I ran the fixed function directly, the same way I'd read it:
evaluateRisk({
tokenFlows: [{ amount: '∞', symbol: 'Token approval', /* … */ }],
})
// → { riskLevel: 'high', riskFlags: [{ label: 'Unlimited Approval', … }] }
evaluateRisk({
ethFlow: { /* ordinary 0.01 ETH transfer */ },
})
// → { riskLevel: 'safe', riskFlags: [] }
The second call matters as much as the first. A fix that makes everything flag high is just a different kind of broken, so the control case, the same one from Part 1's mock harness, has to still come back clean.
Two more silent failures, found the same way
Once I stopped trusting "it compiled" as a stand-in for "it works," two more problems turned up nearby, both sharing the exact shape of the one above: something fails quietly, and a try/catch or a missing build step hides it from view.
Buffer doesn't exist in a service worker. The function that decodes a transaction's revert reason used Buffer.from(hex, 'hex').toString('utf8') to turn the raw return data into readable text. Buffer is a Node.js global. The background script runs as an actual Manifest V3 service worker, a browser environment, not Node, and Buffer is simply undefined there. Every call threw, every throw landed in a surrounding catch, and the UI fell back to a generic message: "transaction would revert," with no reason. The feature had never once worked, and nothing said so. The fix swaps in TextDecoder, the browser-native equivalent.
The project had never actually been typechecked. vite build uses esbuild to transpile TypeScript, and esbuild strips types without checking them: that's what makes it fast. There was no separate tsc --noEmit step anywhere in the project's scripts, so none of this had ever been verified against the type checker it was written with. Once I added that script and ran it, it surfaced the Buffer issue again, from a different angle: Cannot find name 'Buffer', a declaration-level version of the exact bug above. The fact that both paths pointed at the same line independently is a decent argument that the bug was real rather than a one-off. It also meant window.ethereum's type had never been declared anywhere in the codebase, and vite.config.ts's own Node-only APIs (__dirname, path) were leaking into the same type-checking pass as the browser code, which is the general version of the Buffer mistake: nothing was stopping a Node-only API from being used somewhere it would only ever fail silently at runtime.
None of these three bugs would show up in a demo. They all share the same failure mode: something that looks like a result, isn't flagged as suspicious, and is wrong. A missing flag doesn't look different from an absent one. A caught exception doesn't look different from success. A green build doesn't look different from a checked one. The demo only exercises the paths you decided to click through, and "it compiled" was never actually evidence that any of this worked.
What should actually be on the badge
The deeper issue outlives the specific bug. "LOOKS SAFE" is a single, confident word standing in for two situations that should never share a label:
- The decoder understood the call completely, and none of the five active rules fired.
- The decoder couldn't fully make sense of the call, so there was nothing to check it against.
A reassuring label is the wrong output for the second case. It isn't a finding, it's the absence of one, and treating absence of evidence as evidence of safety is exactly how an attacker's own unlisted token walked through the version I'd just built with the badge intact.
I haven't shipped a fix for this yet, and I don't want to claim otherwise. The honest version is a tri-state verdict: risky, no known risks found, or couldn't fully evaluate this. Even just renaming the label from "LOOKS SAFE" to "NO KNOWN RISKS FOUND" would be a real improvement with no code change at all, because it stops implying a judgment the tool didn't actually make.
A second asymmetry, caught while writing this post
Rereading the risk rules for this post, rather than just to patch the one bug I already knew about, turned up something I hadn't looked for: the unlimited-amount check only exists for one of the two approval mechanisms TxnLense decodes.
A plain EIP-2612 Permit with the maximum value gets exactly the escalation you'd want:
evaluateRisk({ typedDataType: 'Permit', typedDataMessage: { value: MAX_UINT256 } })
// → riskLevel: 'high', label: 'Unlimited Permit Signature'
A Permit2 PermitSingle with the same maximum amount does not:
evaluateRisk({ typedDataType: 'PermitSingle', typedDataMessage: { details: { amount: MAX_UINT160 } } })
// → riskLevel: 'medium', label: 'Permit2 Signature'
The Permit2 rule flags every Permit2 signature at a flat "medium," with a generic "verify the spender and expiration," regardless of amount. It never reads details.amount at all. An unlimited Permit2 grant, structurally the same risk as the unlimited approval this whole post is about, gets less visual weight than a five-dollar one. I haven't fixed this yet either. It's going in the next pass, and I'm naming it here rather than quietly patching it before anyone noticed, for the same reason as the badge: a post about a tool overstating what it checked shouldn't itself overstate what's already fixed.
One more small thing worth knowing if you read the source: the risk levels are high, medium, low, and safe, and the popup has styling for all four. But every rule that pushes a flag currently sets its level to high or medium explicitly. There's no path through the current rules that produces low — it's reachable in the type and in the UI, not in the logic. Not a bug exactly, since nothing breaks, but worth knowing the fourth state is aspirational right now, not active.
What this can't promise, even once every bug above is fixed
Writing all of this down is also a reasonable time to be honest about the ceiling, not just the bugs under it.
It runs in the same world it's watching. The interception patch lives in the page's own JavaScript context, which Part 1 covered as the reason it can see window.ethereum at all. The flip side is that a sufficiently hostile page shares that same context and could, in principle, detect or interfere with a patch running alongside it. "Informational only, never blocks" lowers the stakes of a false positive. It doesn't make the patch invisible to the page it's sitting on.
It only patches window.ethereum. A newer connection pattern, EIP-6963, lets multiple wallets announce themselves without all of them colliding on that one global. I haven't checked whether a dapp using that pattern exclusively would ever call through the object TxnLense patches, or whether it would route around it entirely. I'm flagging it as an open question rather than a confirmed gap, because I'd rather say "I don't know yet" than quietly assume coverage I haven't verified.
Function names come from a public, user-submitted database. When calldata doesn't decode against a known ABI, the fallback looks up the selector on 4byte.directory, which is editable by anyone. A wrong or malicious submission there would come back as a wrong name here. It's a reasonable fallback for a project at this stage, and it's not infallible in a way worth remembering before trusting an unfamiliar function name at face value.
None of these are reasons not to build the thing. They're the difference between "this extension flags some real risks" and "this extension flags all risk," and that difference is exactly what a badge that just says "LOOKS SAFE" was quietly erasing.
The through-line across both of these posts, and honestly across this whole run of writing about my own bugs, is the same each time: the parts that look finished are the parts I'd stopped checking, not the parts that were actually correct. The fix was never cleverness. It was going back and checking the part I'd already decided was fine.
If you want to look yourself rather than take any of this on faith: github.com/Aniket98Misra/TxnLense.
Top comments (0)