Fraud ring detection in under 50ms
Objective
Fraud rings move money in circles — A pays B pays C pays A — to make stolen funds look like legitimate revenue. The pattern is invisible to row-level rules but obvious as a cycle in the transaction graph. This recipe builds a small money-flow graph with one embedded ring and finds it with a single MATCH on a closed path.
Step 1: Create the graph
MERGE (a:Account {id: "ACC-001", holder: "Sarah Chen", country: "US"})
MERGE (b:Account {id: "ACC-002", holder: "Acme Logistics", country: "US"})
MERGE (c:Account {id: "ACC-003", holder: "Bay Tech LLC", country: "US"})
MERGE (d:Account {id: "ACC-004", holder: "Raj Patel", country: "US"})
MERGE (e:Account {id: "ACC-005", holder: "Mia Rossi", country: "IT"})
MERGE (f:Account {id: "ACC-006", holder: "Leo Park", country: "KR"})
MERGE (g:Account {id: "ACC-007", holder: "Zoe Williams", country: "UK"})
MERGE (h:Account {id: "ACC-008", holder: "Eli Tanaka", country: "JP"})
// Legitimate-looking cash flow
MERGE (a)-[:TRANSFER {amount: 1200, date: "2026-04-02"}]->(b)
MERGE (b)-[:TRANSFER {amount: 980, date: "2026-04-03"}]->(g)
MERGE (g)-[:TRANSFER {amount: 600, date: "2026-04-05"}]->(h)
MERGE (d)-[:TRANSFER {amount: 1500, date: "2026-04-06"}]->(e)
// THE RING: c -> d -> e -> f -> c, all over 50k, within a tight window
MERGE (c)-[:TRANSFER {amount: 78000, date: "2026-04-10"}]->(d)
MERGE (d)-[:TRANSFER {amount: 76500, date: "2026-04-11"}]->(e)
MERGE (e)-[:TRANSFER {amount: 74000, date: "2026-04-12"}]->(f)
MERGE (f)-[:TRANSFER {amount: 71500, date: "2026-04-13"}]->(c)
// Some additional decoy flow
MERGE (h)-[:TRANSFER {amount: 250, date: "2026-04-14"}]->(a)
MERGE (b)-[:TRANSFER {amount: 410, date: "2026-04-15"}]->(g);
Step 2: Find suspicious cycles
// Closed cycles of length 3 to 4 with every hop above $50k.
MATCH cycle = (start:Account)-[t:TRANSFER*3..4]->(start)
WHERE ALL(r IN t WHERE r.amount > 50000)
RETURN [n IN nodes(cycle) | n.id] AS ring_accounts,
[r IN relationships(cycle) | r.amount] AS hop_amounts,
reduce(s = 0, r IN relationships(cycle) | s + r.amount) AS total_moved;
What's happening
- Cypher's variable-length pattern
*3..4plus the same variable on both ends (start ... start) expresses "closed cycle" directly — the engine prunes paths that don't close. ALL(r IN t WHERE r.amount > 50000)filters out rings of trivial amounts at traversal time.reduce()totals the laundered amount inline, no second query.- In SQL this needs a 4-way self-join with equality on the start/end account plus AML thresholds —
N^4 cost on the transaction table. On the graph it's local: only edges from
startare visited. - Tight latency (<50ms on millions of edges) means rules can run on every transfer in real-time.
Try this next
MATCH (a:Account)-[t:TRANSFER]->(b:Account)
RETURN a.country AS from_country, b.country AS to_country,
count(t) AS transfers, sum(t.amount) AS volume
ORDER BY volume DESC;
MATCH (a:Account)-[:TRANSFER*2..3]->(a)
RETURN DISTINCT a.id, a.holder;
MATCH (a:Account)-[t:TRANSFER]->(b:Account)
WHERE t.amount > 70000
RETURN a.holder AS sender, b.holder AS receiver, t.amount, t.date
ORDER BY t.date;