Anti-money-laundering layering detection
Objective
Layering is the AML stage where dirty money is moved through a long chain of accounts to obscure its origin. Compliance teams need to find chains longer than three hops where the value preserved end-to-end is high. The graph view turns this into a single MATCH that traces money flow across banks, jurisdictions, and shell companies in one shot.
Step 1: Create the graph
// Accounts (with bank + jurisdiction)
MERGE (a:Account {id: "ACC-A", holder: "Sarah Chen", bank: "Bay Trust", country: "US"})
MERGE (b:Account {id: "ACC-B", holder: "Helios Holdings", bank: "Bay Trust", country: "US"})
MERGE (c:Account {id: "ACC-C", holder: "Vortex Trading", bank: "Cayman Bank", country: "KY"})
MERGE (d:Account {id: "ACC-D", holder: "BlueRock Ltd", bank: "Riga Bank", country: "LV"})
MERGE (e:Account {id: "ACC-E", holder: "Atlas Shell", bank: "Sofia Bank", country: "BG"})
MERGE (f:Account {id: "ACC-F", holder: "Mia Rossi", bank: "Banca Roma", country: "IT"})
MERGE (g:Account {id: "ACC-G", holder: "Polar Imports", bank: "Tallin Bank", country: "EE"})
MERGE (h:Account {id: "ACC-H", holder: "Leo Park", bank: "Seoul Bank", country: "KR"})
MERGE (i:Account {id: "ACC-I", holder: "Eli Tanaka", bank: "Tokyo Bank", country: "JP"})
// Transfer edges (amount in USD, date)
// Layered chain: A -> B -> C -> D -> E -> F (5 hops, high preserved value)
MERGE (a)-[:TRANSFER {amount: 250000, date: "2026-04-01"}]->(b)
MERGE (b)-[:TRANSFER {amount: 245000, date: "2026-04-03"}]->(c)
MERGE (c)-[:TRANSFER {amount: 240000, date: "2026-04-05"}]->(d)
MERGE (d)-[:TRANSFER {amount: 235000, date: "2026-04-07"}]->(e)
MERGE (e)-[:TRANSFER {amount: 230000, date: "2026-04-09"}]->(f)
// Second suspicious chain: G -> H -> I (only 2 hops; should NOT trigger)
MERGE (g)-[:TRANSFER {amount: 180000, date: "2026-04-02"}]->(h)
MERGE (h)-[:TRANSFER {amount: 178000, date: "2026-04-04"}]->(i)
// Decoy noise transfers
MERGE (a)-[:TRANSFER {amount: 1200, date: "2026-04-10"}]->(g)
MERGE (h)-[:TRANSFER {amount: 800, date: "2026-04-12"}]->(b)
MERGE (f)-[:TRANSFER {amount: 50, date: "2026-04-15"}]->(i);
Step 2: Detect layering chains
// Look for chains of length >3 where every hop is over $50k AND >85% of the value
// is preserved end-to-end (a hallmark of pass-through layering).
MATCH path = (src:Account)-[t:TRANSFER*4..6]->(dst:Account)
WHERE ALL(r IN t WHERE r.amount > 50000)
WITH src, dst, path, t,
head(t).amount AS source_amount,
last(t).amount AS final_amount
WHERE final_amount * 1.0 / source_amount > 0.85
WITH src, dst, path,
[n IN nodes(path) | n.id] AS account_chain,
[n IN nodes(path) | n.country] AS country_chain,
length(path) AS hop_count,
reduce(s = 0, r IN relationships(path) | s + r.amount) AS total_volume
RETURN account_chain, country_chain, hop_count, total_volume
ORDER BY hop_count DESC, total_volume DESC;
What's happening
TRANSFER*4..6only matches chains of at least four hops — exactly where layering lives. Direct transfers and 2-hop "round-trip" patterns are ignored.head(t)andlast(t)access the first and last edges of the path to compute value retention, the strongest layering signal: if 90% of $250k arrives at the destination, it likely passed through unchanged.country_chainreveals jurisdiction-hopping (US → KY → LV → BG → IT here) — auditors immediately see the geographic footprint.- In SQL this is essentially impossible without an iterative loop in application code — recursive CTEs that emit path arrays are a Postgres-specific extension and still require complex window arithmetic for retention scoring.
- Run this incrementally on every new transfer to alert the FIU within seconds, not after a batch.
Try this next
MATCH (a:Account)-[t:TRANSFER]->(b:Account)
WHERE a.country <> b.country AND t.amount > 100000
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 path = (a:Account)-[:TRANSFER*3..]->(z:Account)
WHERE NOT (z)-[:TRANSFER]->()
RETURN a.holder AS originator, z.holder AS terminus, length(path) AS hops
ORDER BY hops DESC LIMIT 5;
MATCH (a:Account)-[t:TRANSFER]->(b:Account)
WITH a, count(t) AS out_degree
WHERE out_degree >= 3
RETURN a.id, a.holder, a.country, out_degree
ORDER BY out_degree DESC;