๐Ÿ“œ The Blaze Papers โ† back to sonicblaze.xyz

๐Ÿ“œ The Blaze Papers / The Great Revoker

๐Ÿงน The Great Revoker

Every approval your wallet has ever handed out on Sonic, what each one can actually take from you today, and a button to take it back. Here's exactly how it finds them, what it can't see, and why clearing ten approvals still costs ten signatures.

What an approval actually is

๐Ÿฅฑ degen tl;dr

Approving a dapp isn't a one-time "yes" โ€” it's a standing permission that outlives the transaction you signed it for. You click once, the dapp gets to move up to that much of your token forever, until something explicitly turns it back off. Most people never turn it back off.

๐Ÿค“ the nerd stuff

An ERC-20 approve(spender, amount) call writes directly into the token contract's own allowance[owner][spender] storage slot. There's no expiry field anywhere in the standard โ€” that number sits there exactly as written until a later approve() overwrites it (spending doesn't zero it either; transferFrom just decrements it).

NFT approvals work the same way in spirit: setApprovalForAll(operator, true) flips a permanent boolean, and a single-token approve(spender, tokenId) names one spender for one token until reassigned. Every one of those states persists on-chain whether or not the dapp that asked for it is still even running.

How the Revoker finds them

๐Ÿฅฑ degen tl;dr

Two log sweeps to find every approval you've ever granted, then a live on-chain check per hit to see which ones are still real. Logs discover, calls decide. An approval you already revoked months ago is still sitting in the log history forever โ€” the live check is what drops it before it ever reaches your screen.

๐Ÿค“ the nerd stuff

Discovery runs two eth_getLogs calls, each filtered to one event topic0 (Approval or ApprovalForAll) and to your address as the indexed owner topic, over the whole chain from block zero. Owner- filtering is what keeps each call small โ€” a few dozen logs in well under a second โ€” rather than the collection-wide scans that blow the public RPC's result cap (see docs/measured-facts.md ยง3).

Every hit becomes a candidate, deduped to the newest log per (kind, token, spender, tokenId). Candidates then go through verify(), one live eth_call each โ€” allowance(), isApprovedForAll(), or getApproved() depending on kind.

Anything that reads back zero, false, or a different spender is dropped outright. Only what verify() confirms is still live ever gets enriched and rendered.

The ERC-721 collision

๐Ÿฅฑ degen tl;dr

ERC-20 token approvals and ERC-721 NFT approvals emit an event with the exact same name and signature, so a log-based sweep sees them as identical at first glance. Confuse the two and a token ID renders as if it were a spending allowance โ€” nonsense. The Revoker tells them apart by counting topics, not by guessing.

๐Ÿค“ the nerd stuff

Approval(address,address,uint256) hashes to the identical topic0 whether the emitting contract is an ERC-20 or an ERC-721 โ€” the event signature text is byte-identical either way, so the hash can't discriminate.

The discriminator is topic count: ERC-721 indexes tokenId as a topic, giving the log 4 topics total; ERC-20 leaves the value in the unindexed data payload, giving it 3. classifyLog() branches on topics.length โ€” never on an interface probe, which would cost an extra round trip per candidate for something the log itself already tells you for free.

What "no contract code" means

๐Ÿฅฑ degen tl;dr

Sometimes the address you approved has no bytecode at all. That's either a dead contract โ€” one that self-destructed or was never really deployed the way you thought โ€” or it's just somebody's plain wallet. The Revoker names both readings and asserts neither, because the on-chain data genuinely can't tell them apart.

๐Ÿค“ the nerd stuff

Every spender gets one eth_getCode call. An empty result ("0x") means there is no bytecode at that address right now โ€” full stop. It does not mean "safe" and it does not mean "scam": a genuinely dead contract and a plain externally- owned account produce the identical empty response.

The row gets a "โš ๏ธ no contract code here" badge either way, and it ranks at the very top of the risk ordering, because the second reading โ€” an ordinary wallet holding an open approval โ€” is exactly what a drainer looks like from the chain's point of view.

A read failure on getCode assumes code exists rather than flagging a false alarm โ€” the badge only ever fires on a confirmed empty result.

Balance at risk, not raw allowance

๐Ÿฅฑ degen tl;dr

A scary-looking unlimited allowance on a wallet holding zero of that token exposes exactly nothing today. The Revoker ranks and displays what's actually at risk right now โ€” min(allowance, balance) โ€” not the raw number you signed, which can make an empty bag look like the biggest threat on the page.

๐Ÿค“ the nerd stuff

For an ERC-20 row, atRisk = min(allowance, balance) โ€” you cannot lose what you do not hold, so a huge approval on a near-empty wallet is capped by the balance, not the allowance.

For NFT-shaped rows there's no partial exposure to cap: an operator (setApprovalForAll) approval exposes your whole collection balance in that contract, and a single-token approval exposes exactly the one tokenId it names โ€” it can never touch the rest of your holdings in that collection, and the page's copy says so explicitly rather than implying the whole collection is on the line.

Why there is no mass revoke

๐Ÿฅฑ degen tl;dr

There's no "revoke all" button, and there never will be one. approve() only ever listens to whoever calls it โ€” your wallet, and only your wallet โ€” so ten approvals means ten separate signatures, one transaction each. Any tool that claims it can clear a pile of approvals in one signature is overselling what the standard allows.

๐Ÿค“ the nerd stuff

approve() and setApprovalForAll() are both msg.sender-scoped by the ERC-20/ERC-721 standards themselves โ€” there is no batched or delegated form defined anywhere in either spec, and a token contract has no reason to add one just for revocation.

The revoke queue reflects that constraint honestly: it walks one signature at a time, waits for each transaction to land, then re-reads the exact same call verify() uses to confirm the allowance is actually gone before moving to the next row.

A rejection halts the walk with the remainder untouched on screen โ€” nothing auto-continues, and nothing is ever sent you didn't individually sign for.

The Permit2 blind spot

๐Ÿฅฑ degen tl;dr

Stated plainly: Uniswap's Permit2 holds allowances inside its own contract, separate from the token's ordinary allowance mapping. The Revoker can show you that you approved Permit2 itself โ€” it cannot see what Permit2 is allowed to do downstream on your behalf. Anyone claiming a complete picture of your approvals without a dedicated indexer is overselling it, and that includes us.

๐Ÿค“ the nerd stuff

A standard ERC-20 approve() to the Permit2 contract address shows up in the sweep exactly like any other row โ€” that part is fully visible. What's invisible is everything inside Permit2: its own per-spender, per-token, time-bound allowances live in Permit2's storage, granted by off-chain signatures that never touch the ordinary Approval event topics this page filters on.

Seeing those would mean reading Permit2's own mappings directly (or running an indexer built for it), which is out of scope for a two-log-sweep tool. Revoking your approval to Permit2 itself removes Permit2's ability to move funds it doesn't already have standing permission for, but any permission already granted downstream through it needs to be managed on its own terms.