Every honeypot checker on npm is an API wrapper. GoPlus, honeypot.is, TokenSniffer — they all call someone else's server. If that server goes down, adds a paywall, or starts rate limiting you, your tool is dead.
We wanted something different. A scanner that runs entirely against an RPC endpoint. No API keys. No external services. Just eth_getCode, eth_call, and eth_getStorageAt.
Here's how we built it.
What can you actually check on-chain?
More than you'd think. An EVM contract's bytecode contains function selectors — the first 4 bytes of the keccak256 hash of each function signature. By extracting these selectors from raw bytecode, you can detect:
-
Mint functions —
mint(address,uint256)=0x40c10f19 -
Pause/unpause —
pause()=0x8456cb59 -
Blacklist —
blacklist(address)=0xf9f92be4 -
Fee manipulation —
setTaxFeePercent(uint256)=0x061c82d0 -
Owner control —
owner()=0x8da5cb5b,renounceOwnership()=0x715018a6
The extraction is straightforward. Solidity compilers emit PUSH4 (opcode 0x63) followed by 4 bytes, then EQ (0x14) for the function dispatcher:
for (let i = 0; i < code.length - 10; i += 2) {
if (code[i] === '6' && code[i + 1] === '3') {
const sel = code.slice(i + 2, i + 10)
const lookAhead = code.slice(i + 10, i + 50)
if (lookAhead.includes('14')) {
selectors.add(sel.toLowerCase())
}
}
}
Match these against a database of known dangerous selectors, and you've got a bytecode-level risk assessment without ever calling an external API.
Proxy detection is trickier than it looks
USDC was our wake-up call. We implemented EIP-1967 storage slot checking:
const IMPLEMENTATION_SLOT = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc'
const implAddr = await getStorageAt(rpcUrl, address, IMPLEMENTATION_SLOT)
Worked great for most proxies. Then USDC returned zero. Turns out USDC uses a ZeppelinOS proxy pattern with a different storage slot: keccak256("org.zeppelinos.proxy.implementation").
We ended up checking four patterns:
-
EIP-1167 — minimal proxy bytecode pattern (
363d3d373d3d3d363d73...) - EIP-1967 — standard transparent/UUPS proxy storage slot
-
EIP-897 —
implementation()function call -
ZeppelinOS —
org.zeppelinos.proxy.implementationslot
Each contract only matches one, but you need all four to cover real-world usage.
The EIP-7702 surprise
While testing, we pointed the scanner at Vitalik's address. Expected result: "not a contract." Actual result: it was flagged as a contract.
Turns out Vitalik had set an EIP-7702 delegate designation — a short bytecode starting with 0xef0100 followed by a 20-byte address. It's not a real contract, it's a delegation pointer.
function isContract(bytecode: string): boolean {
const code = bytecode.startsWith('0x') ? bytecode.slice(2) : bytecode
if (code.length === 0 || code === '0') return false
// EIP-7702 delegate designation
if (code.toLowerCase().startsWith('ef0100') && code.length <= 46) return false
return true
}
Small edge case, but the kind that matters when you're scanning thousands of addresses.
Honeypot detection via DEX simulation
The most interesting check is honeypot detection. The idea: simulate a buy and sell through a DEX router using eth_call. If the sell reverts, it's a honeypot.
We call swapExactETHForTokensSupportingFeeOnTransferTokens (the fee-on-transfer variant, which handles tax tokens) with a simulation wallet address. If the buy succeeds but the sell fails, the token can't be sold — classic honeypot.
This works without any API because eth_call is a read-only simulation. No gas is spent, no transaction is broadcast. You're just asking the node "what would happen if...?"
Risk scoring
Each check contributes to a 0-100 risk score:
| Signal | Points |
|---|---|
| Honeypot detected | +40 |
| Mint function exists | +15 |
| Blacklist function | +15 |
| Pause function | +10 |
| Transfer fees | +10 |
| No liquidity | +10 |
| Owner not renounced | +5 |
| Proxy contract | +5 |
| Ownership renounced | -10 |
A score of 0-10 is "safe," 75+ is "critical." It's opinionated, but it gives you a starting point for automated filtering.
What we shipped
The package runs all checks in parallel, supports 7 chains with built-in DEX configurations, and weighs about 28KB with zero dependencies.
import { scanContract } from '@pulsadev/contract-scanner'
const result = await scanContract('0xTokenAddress...', {
rpcUrl: 'https://ethereum-rpc.publicnode.com',
})
console.log(result.riskLevel) // 'safe' | 'low' | 'medium' | 'high' | 'critical'
console.log(result.checks.honeypot.isHoneypot)
console.log(result.checks.ownership.isRenounced)
console.log(result.metadata.dangerousFunctions)
Each check is also exported individually, so you can use just the parts you need.
npm: @pulsadev/contract-scanner
GitHub: pulsadev/contract-scanner
If you're building DeFi tooling or security dashboards, I'd love to hear how you're handling on-chain analysis. What checks would you add?
Top comments (0)