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:
- Fetches verified source via Etherscan V2 (16 chains)
- Runs a deterministic pattern engine — 19 rules, each with context-aware suppression
- Runs an LLM layer (Groq) for contextual analysis
- Merges, dedupes, caps findings per family
- 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
internalfunction 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.

Top comments (2)
Respect for publishing the "what it doesn't catch" table, most scanner posts skip that part. And 3 out of 4 not being pattern-matchable fits what we see in audits, the expensive bugs are usually about what the protocol is supposed to do.
One thing I'd double check: your arbitrary-call example has
require(success)right on the next line. If the 250-char require check also applies to that rule, it would suppress the exact FlashLoopAdapter case. I guess it's only on the unchecked-return rule?Same with the ecrecover suppression.
nonceshowing up nearby doesn't mean it's checked and incremented, or that the signed hash includes chainid and the contract address. Have you tried it on old contest repos with known findings? 8 contracts is a small set to read 66% from.Really appreciate you actually reading the code instead of skimming the table, one of the main reason I posted this was to get this kind of feedback.
On the require(success) thing , I just checked it & yes, that suppression only lives in unchecked-low-level-call. The arbitrary-call-user-calldata rule only suppresses on only* modifiers and require(msg.sender == ...) checks. FlashLoopAdapter's forwardRequest has neither, so the finding stands but thank God you asked, it made me actually crosscheck, if the same check had leaked across, the whole test case would've been invalid
Concerning the ecrecover one, "nonce appears within 800 chars" is not the same as "nonce is checked, incremented, and included in the signed hash." Right now it's string adjacency, not semantics. It under-fires on real replay bugs where the nonce exists but isn't wired up properly. I'd actually have to parse the actual keccak256 args and verify nonce + chainid + address(this) are inside the hash, still on the list, but it's a valid constraint/limitation
Actually that was what I was planning to do, scan approx. 10 Code4rena/immunefi contests with public High findings and running the bot against them so I can report actual precision & recall. 8 contracts just gave me a story, not a number.
I really appreciate this feedback once again, means a lot @bloqarl