The 1 HIGH (reentrancy at line 188) and 3 MEDIUMs (unchecked transfers) from the scan are likely false positives or lower-priority — the exploit itself was the two CRITICALs.

I kept reading post-mortems of exploits and noticing the same pattern: the vulnerable code had a bug class that any competent auditor would flag in 30 seconds, but the protocol team shipped it anyway.

Red Village. FlashLoopAdapter. SquidRouter. All three had pattern-matchable bugs — arbitrary external calls, unverified return values, fake-Safe vouching. Not deep protocol reasoning, just basic access control failures.

The scanner tools that existed either required paid APIs, or produced so many false positives that they were useless.

What GuardContract Does

You send a Telegram bot a contract address. It:

  1. Fetches verified source via Etherscan V2 (16 chains)
  2. Runs a deterministic pattern engine — 19 rules, each with context-aware suppression
  3. Runs an LLM layer (Groq) for contextual analysis
  4. Merges, dedupes, caps findings per family
  5. Replies with a structured report

Live example — FlashLoopAdapter, October 2026, ~$305K stolen:

🔴 CRITICAL — Arbitrary External Call at FlashLoopAdapter.sol:189 🟠 HIGH — Trusting msg.sender to Self-Report at FlashLoopAdapter.sol:92 

Both findings describe the actual exploit path. The bot caught it from source code alone.

Architecture: Why Two Layers

An LLM cannot be the sole decision-maker for security. It's non-deterministic, hallucinates line numbers, and has no way to verify that a claimed vulnerability is actually exploitable.

A regex-only scanner is also useless if it fires on every .call( and produces 20 findings on a clean contract.

The architecture is:

  • Pattern layer — deterministic. Same input, same output. Fires only when the syntactic pattern is present and context checks pass.
  • LLM layer — contextual. Catches "this auth flow has a design flaw" that regex never would. Tagged as hypotheses, not facts.
  • Verification pass — snippet-to-line matching. If the LLM claims "issue on line 42" but the code snippet doesn't match line 42, downgrade to LOW confidence or drop.
  • Caller classifier — walks the call graph. If a finding is in an internal function that's reachable from a public one, tags it as publicly reachable instead of private.

The Tuning Journey

I scanned 8 contracts. Each one exposed a class of false positives I hadn't anticipated.

Contract 1 — EthPrime (v0.5.13): 22 findings. Turned out to be ~3 real. The pattern layer was firing on every .call.value() — but most were wrapped in require() on the next line.

Fix: contextCheck looks 5 lines ahead for require(...) or if (success).

Contract 2 — Exchange (v0.4.25): 5 findings-:signature replay × 3, unbounded loop × 2 which were all false positives.

The signature-replay rule fired on every ecrecover. But the code had nonce tracking and withdrawn[hash] mappings which are the standard replay protections.

Fix: suppress if nonce, withdrawn[, orderFills[, or address(this) appears near the ecrecover.

Contract 3 — HaiGovernor (OpenZeppelin): 6 findings. All in node_modules/@openzeppelin/.

Fix: skip node_modules/, lib/, @openzeppelin/, @chainlink/. Auditors don't audit OZ.

Contract 4 — FlashLoopAdapter: 5 findings. Two of them, both the arbitrary call and the fake-Safe check, all matched the actual exploit. First true positive.

Contract 5 — Sepolia test contract: I deployed a deliberately-vulnerable router, verified it, and rescanned. The arbitrary-call-user-calldata rule fired CRITICAL. End-to-end validation.

The Two Rules That Matter Most

1. arbitrary-call-user-calldata (CWE-829)

Fires when a public/external function forwards caller-supplied calldata to an external .call(). If the contract holds any privileged role, an attacker can invoke that privilege on their behalf.

function forwardRequest(bytes calldata _funcEncodeWithSignature) external { address service = getService(); (bool success, ) = service.call(_funcEncodeWithSignature); // ← fires require(success); } 

This was the FlashLoopAdapter bug. The router held _authorizeUpgrade on a UUPS proxy. Anyone could call forwardRequest with upgradeTo(attackerContract) as the calldata.

2. trust-msg-sender-selfreport (CWE-807)

Fires when code calls back into msg.sender and trusts the response as its only authorization check.

if (!ISafe(msg.sender).isModuleEnabled(address(this))) revert; // ← fires 

The FlashLoopAdapter check- A fake Safe contract returns true for anything,the check always passes.

The Real Constraint: False Positives

Anyone can write a regex that matches .call(. The hard part is writing one that doesn't match .call( inside require(success, ...).

Every rule has a contextCheck function that receives the matched index and can suppress itself:

contextCheck: function(source, idx) { var after = source.slice(idx, idx + 500); if (/require\s*\(/.test(after.slice(0, 250))) return false; if (/if\s*\(\s*(?:!\s*)?(?:success|ok|sent|result|ret)/i.test(after.slice(0, 250))) return false; return true; } 

That single check, "does this call get checked in the next 250 chars?" eliminated ~80% of false positives in that rule.

What It Doesn't Catch

Exploit Why the bot missed it
Red Village (Sep 2026, $1.2M) Router source was unverified — nothing to scan
MakerDAO Keeper (Oct 2026, $538K) Deployment misconfiguration (authority=0x0) — a runtime state bug, not a code bug
GPXHooks (Oct 2026, $114K) V4 hook accounting semantics — no static scanner catches this
SquidRouter (Sep 2026, $3.2M) Caller-supplied string trusted as proof — pattern is similar to rule #2, would fire

Three out of four recent exploits are not pattern-matchable. That's the frontier.

Results After 8 Contracts

Metric Before After
Total findings across corpus 49 12
Real findings 3-4 3-4
Precision ~25% ~66%
False negatives on verified exploits — 1 (GPXHooks)

66% precision on real contracts

Stack

  • Node.js
  • Etherscan V2 API (16 chains)
  • Groq (openai/gpt-oss-120b) for LLM layer
  • grammY for Telegram bot
  • Local JSON cache (24h TTL)

Total infrastructure cost: $0. Free API tiers handle everything.

Repo

GitHub: github.com/superwavvy/guardcontract

Would love feedback on the rule design — especially from anyone who's tried to write LLM security tooling and hit the same false-positive wall.