DEV Community

Cover image for What a call-graph traversal sees that contract-by-contract review cannot
Mabera
Mabera

Posted on

What a call-graph traversal sees that contract-by-contract review cannot

Most smart-contract review reads one contract at a time. You open the file, you look for the usual shapes — reentrancy, unchecked calls, oracle reads — and you write down what you find. The contract is the unit of analysis, and the audit report is a list of findings about that contract.

But the largest exploits of the last decade did not happen inside one contract. They happened on a route through several.

This post is about building an agent that refuses to certify a contract when its call graph reaches a weakness a real exploit actually used — and what the graph told me that reading contracts one at a time does not.

The thing that doesn't show up contract-by-contract

I seeded a small graph — 144 nodes and 217 edges at seed time (23 contracts, 76 functions, 34 CALLS edges, 20 SWC patterns, 10 exploits, plus libraries and auditors; 160 nodes once the agent files its own verdicts). Then I asked one question of it:

MATCH p = (a:Function)-[:CALLS*1..6]->(b:Function)
WHERE a.contract <> b.contract
RETURN a.contract, a.name, b.contract, b.name, length(p)
ORDER BY length(p) DESC
Enter fullscreen mode Exit fullscreen mode

30 routes cross a contract boundary. Some of them are worth staring at:

from to hops
bZx.executeTrade AaveV2LendingPool.flashLoan 2
bZx.executeTrade AaveV2LendingPool.executeOperation 3
bZx.liquidate cUSDC.redeem 3
MakerVault.liquidate cUSDC.redeem 2
1inchv3.swap UniswapV2Router02._swap 2

Every one of these functions is individually unremarkable. cUSDC.redeem is not a vulnerability. AaveV2LendingPool.flashLoan is the feature working as designed. MakerVault.liquidate is a liquidation function doing liquidation.

The risk is not in any node. It is in the route — and a review that opens one file at a time never sees a route, because a route lives in the joins between files.

That is the whole thesis, and it is why the graph is not decoration here. There is no contract you can point at and say "this is the dangerous one." There is a path.

Making the graph say what's actually dangerous

Once you can walk routes, the next problem is that most of them are fine. You need a rule that separates a route worth refusing from a route that is just... code calling code.

The naive rule is "flag if any node on the path has a vulnerability." That rule is bad, and it took a real run to show me how bad.

What I used instead: a route blocks only if a node on it carries a vulnerability that is connected to a real exploit — a two-hop join, evaluated per node on the path:

UNWIND $path AS fname
MATCH (f:Function {name: fname})-[hv:HAS_VULNERABILITY]->(v:Vulnerability)
WHERE hv.status IN ['exploited', 'pattern']
OPTIONAL MATCH (v)-[:EXPLOITED_BY]->(e:Exploit)
RETURN fname, v.swc, v.title, v.severity, hv.status,
       collect(DISTINCT e.name) AS exploits,
       collect(DISTINCT e.loss_usd) AS losses
Enter fullscreen mode Exit fullscreen mode

The EXPLOITED_BY edge is doing the real work. A vulnerability pattern that no exploit ever used is a detector flag, not evidence — so the agent drops rows with no exploit behind them. What survives is a claim with teeth: this shape was used, in this incident, for this much money.

The result that made the design worth it

KyberSwapPool gets refused. The graph names its own evidence:

  • route reaching KyberSwapPool.getAmountsOut
  • blocked by SWC-128 (Oracle Manipulation)
  • which the graph connects to five real exploits: KyberSwap Flash Loan Attack ($307M), Cream Finance ($130M), Mango Markets ($112M), Beanstalk ($182M), Euler Finance ($197M)

$928M of documented loss behind one edge in the graph. The agent is not computing a risk score from a heuristic. It is reading the connections and refusing on them, and it can show you the path it read.

And the refusal is written back into the graph rather than into a log — a CertificationRequest{status: BLOCKED} linked via BLOCKED_BY_PATH to the exact functions responsible, with the evidence path stored as JSON on the request. That matters for one reason: the decision becomes replayable. Load the request, re-run the same traversal, get the same verdict. All 12 blocked contracts replay identical.

Two things that caught me out

1. A parameterised variable-length bound doesn't work.

I wanted the hop count configurable:

MATCH p = (a:Function {name: $fn})-[e:CALLS*1..$hops]->(b:Function)
Enter fullscreen mode Exit fullscreen mode

That fails:

Encountered unhandled type in inlined properties
Enter fullscreen mode Exit fullscreen mode

The confusing part is the error text. It says inlined properties, which sends you looking at your property maps — but {name: $fn} works fine, and the parameter is a plain Python int. The failure is specific to the hop bound: it has to be a literal in the pattern. I expanded a validated integer from Python instead, and wrote the reason into the code comment so the next person doesn't lose the same hour.

It fails at inline time, so the error points at the wrong part of the query. If you hit it: it's the *1..N, not your properties.

2. My detection rule was wrong, and the test caught it by disagreeing with itself.

First full run, the agent refused UniswapV2Router02 on an SWC-103 finding tagged pattern. Which was fine, except the verdict's own wording said "used in a real exploit" — and that SWC-103 node had no EXPLOITED_BY edge at all. Zero exploits behind it.

The bug was mine, not the graph's: I was treating "has a vulnerability" as the blocking condition. But a vulnerability node that no exploit ever touched is a theoretical pattern. Filtering those out flipped UniswapV2Router02 to CERTIFIED, which is correct — and it made the verdict text true, which it wasn't before.

The lesson I'd generalise: a detection rule that can't explain itself in the same words it reports is probably wrong. My one-line pitch claimed evidence-based refusal; the code was doing pattern-matching. The gap between the two is where the false positive lived.

What this does not do

Worth being precise, because "AI security tool" invites overclaiming:

  • It does not analyse source code. No Slither, no static analysis, no bytecode. It reasons over vulnerabilities and exploits already recorded in the graph.
  • It is not a live audit tool. The dataset is compiled from public post-mortems and the SWC registry, seeded into the graph. Ingestion from Etherscan is explicitly out of scope.
  • It is not a scanner. It will not find a new bug. It will tell you when the recorded evidence about a contract's reachable functions says "don't sign off on this."

That's a narrower claim than "AI audits your contracts," and it's the one the code actually supports.

Why a graph and not tables

The remove-the-database test: delete FalkorDB and does the product still work?

No. The product's core operation is "walk outward from this function, match what you reach against real exploit shapes, and file the result on the graph." In a table store that is a recursive CTE for the walk, another join chain per node for the matching, and then you have to reconstruct the path from row sets — and you still don't get the route list back in one shot, which is the thing the verdict has to name.

Cypher gives you the route as a value:

MATCH p = (a:Function {name: $fn})-[e:CALLS*1..6]->(b:Function)
RETURN length(p) AS hops,
       [n IN nodes(p) | n.name] AS path,
       [n IN nodes(p) | n.contract] AS contracts
Enter fullscreen mode Exit fullscreen mode

path is a first-class result. That's what lets a refusal say "here is the 2-hop route through Aave that got you blocked" instead of "risk detected."

The takeaway

If your domain has relationships that carry meaning — call graphs, dependency chains, counterparty exposure, attack surfaces — the interesting findings live in the paths, not the rows. A per-entity review will look complete and miss the thing that actually took $928M.

The move that made this work wasn't a clever query. It was asking a question that only has an answer in a graph: is there a route from here to a weakness someone has already used?


Built for Graph Hacks 2026 (WeMakeDevs × FalkorDB), track: Agents That Act on Connected Data. Source: github.com/HusseinAdeiza/graphhacks-refusal-agent. Built with AI coding assistance (Hermes Agent), disclosed per the event rules; every query, schema decision, and verdict rule was verified against a live FalkorDB, and the repo's end-to-end suite (python3 -m agent.verify, 55 checks) is the proof it works as claimed.

Top comments (0)