DEV Community

Ethan Park
Ethan Park

Posted on

On-chain contract security scanning without API keys

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 functionsmint(address,uint256) = 0x40c10f19
  • Pause/unpausepause() = 0x8456cb59
  • Blacklistblacklist(address) = 0xf9f92be4
  • Fee manipulationsetTaxFeePercent(uint256) = 0x061c82d0
  • Owner controlowner() = 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())
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. EIP-1167 — minimal proxy bytecode pattern (363d3d373d3d3d363d73...)
  2. EIP-1967 — standard transparent/UUPS proxy storage slot
  3. EIP-897implementation() function call
  4. ZeppelinOSorg.zeppelinos.proxy.implementation slot

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)