Graph Database Africa · Follow the Ring · live demos

Graph Lab

The three live demos from Follow the Ring, with you in control. They play on their own; click or use the arrow keys to take over.

click or → next step ← previous Autoplay resumes after 30 seconds idle

Click inside the demo once so it receives your arrow keys. All accounts and people in the demos are fictional.

Run the demo yourself in Neo4j Cypher · Aura Free

Paste into the Aura query editor one block at a time. Expected results are in the comments. Download .cypher

// =====================================================================
// Follow the Ring: graph data science for circular fraud networks
// Runnable demo script for Neo4j 5 / Aura Free (Cypher 5).
// Three fictional datasets, matching the three Graph Lab widgets:
//   A. a transfer ring          (ring hunter)
//   B. a scam-money tree        (follow the money)
//   C. a food-delivery collusion ring (shared identifiers)
// Run each block separately in Neo4j Browser or the Aura query tool.
// Blocks marked GDS need the Graph Data Science library
// (Neo4j Desktop or self-managed; not included in Aura Free).
// =====================================================================

// ---------------------------------------------------------------------
// 0. Reset (only on a scratch database!)
// ---------------------------------------------------------------------
MATCH (n) DETACH DELETE n;

CREATE CONSTRAINT account_id IF NOT EXISTS FOR (a:Account) REQUIRE a.id IS UNIQUE;
CREATE INDEX transfer_at IF NOT EXISTS FOR ()-[t:TRANSFERRED]-() ON (t.at);

// =====================================================================
// A. THE RING
// =====================================================================

// A1. Eleven accounts, thirteen transfers
UNWIND [
  ['A-101','A-205',50000,'2026-03-14T09:02'], ['A-205','A-318',48500,'2026-03-14T09:14'],
  ['A-318','A-442',47100,'2026-03-14T09:27'], ['A-442','A-101',45600,'2026-03-14T09:41'],
  ['B-510','B-623',12000,'2026-03-01T10:00'], ['B-623','B-777', 3100,'2026-03-09T10:00'],
  ['B-777','B-510',  800,'2026-03-28T10:00'],
  ['C-904','A-101', 1500,'2026-03-02T10:00'], ['A-205','B-510',  600,'2026-03-03T10:00'],
  ['A-318','C-901',  700,'2026-03-05T10:00'], ['C-901','C-902', 2300,'2026-03-06T10:00'],
  ['C-902','B-777',  950,'2026-03-07T10:00'], ['B-623','C-903', 4000,'2026-03-12T10:00']
] AS row
MERGE (a:Account {id: row[0]})
MERGE (b:Account {id: row[1]})
CREATE (a)-[:TRANSFERRED {amount: row[2], at: datetime(row[3])}]->(b);

// Look at it:
MATCH p = (:Account)-[:TRANSFERRED]->(:Account) RETURN p;

// A2. Every loop of 3 to 6 transfers (expect 2 loops, each returned
//     once per starting account: 4 rows for the A ring, 3 for the B loop)
MATCH p = (a:Account)-[:TRANSFERRED]->{3,6}(a)
RETURN [n IN nodes(p) | n.id] AS loop;

// A3. The ring signature: time-ordered, amounts decaying by at most 10%
//     per hop, whole trip inside one hour.
//     Expect exactly 1 row: start A-101, amounts [50000, 48500, 47100, 45600].
//     The time-order rule also removes the duplicate rotations.
MATCH (a:Account)
      (()-[t:TRANSFERRED]->()){3,6}
      (a)
WHERE all(i IN range(1, size(t)-1)
          WHERE t[i].at > t[i-1].at
            AND t[i].amount >= 0.9 * t[i-1].amount)
  AND t[-1].at < t[0].at + duration('PT1H')
RETURN a.id AS start,
       [x IN t | x.amount] AS amounts,
       duration.between(t[0].at, t[-1].at).minutes AS minutes;

// A4. REAL TIME: test a payment BEFORE it is written.
//     Pretend A-442 -> A-101 (R45,600 at 09:41) has not happened yet:
MATCH (:Account {id:'A-442'})-[t:TRANSFERRED]->(:Account {id:'A-101'}) DELETE t;

//     Anchored check: walk from the receiver back to the sender.
//     Expect 1 row: [A-101, A-205, A-318, A-442] -> hold the payment.
WITH 'A-442' AS fromId, 'A-101' AS toId, 45600 AS amount,
     datetime('2026-03-14T09:41') AS at
MATCH p = (:Account {id: toId})
          (()-[t:TRANSFERRED]->()){2,5}
          (:Account {id: fromId})
WHERE all(i IN range(1, size(t)-1)
          WHERE t[i].at > t[i-1].at
            AND t[i].amount >= 0.9 * t[i-1].amount)
  AND at > t[-1].at
  AND amount >= 0.9 * t[-1].amount
  AND at < t[0].at + duration('PT1H')
RETURN [n IN nodes(p) | n.id] AS ring_this_payment_would_close;

//     Prefix the query above with PROFILE to see how few rows it touches.
//     Then put the transfer back:
MATCH (a:Account {id:'A-442'}), (b:Account {id:'A-101'})
CREATE (a)-[:TRANSFERRED {amount: 45600, at: datetime('2026-03-14T09:41')}]->(b);

// =====================================================================
// B. FOLLOW THE MONEY
// =====================================================================

// B1. Two victims, six mule accounts, one ATM.
//     balance = what is still sitting in the account from this money.
CREATE (v1:Victim {id:'V-1'}), (v2:Victim {id:'V-2'}), (atm:CashPoint {id:'ATM-77'})
WITH v1, v2, atm
UNWIND [['M1',3000],['M2',100],['M3',100],['M4',4500],['M5',0],['M6',7900]] AS row
MERGE (m:Account {id: row[0]}) SET m.balance = row[1];

MATCH (v1:Victim {id:'V-1'}), (v2:Victim {id:'V-2'}), (atm:CashPoint {id:'ATM-77'}),
      (m1:Account {id:'M1'}), (m2:Account {id:'M2'}), (m3:Account {id:'M3'}),
      (m4:Account {id:'M4'}), (m5:Account {id:'M5'}), (m6:Account {id:'M6'})
CREATE (v1)-[:PAID {amount:20000, at:datetime('2026-03-20T14:05')}]->(m1),
       (v2)-[:PAID {amount: 6000, at:datetime('2026-03-19T15:00')}]->(m3),
       (m1)-[:TRANSFERRED {amount:9000, at:datetime('2026-03-20T14:07')}]->(m2),
       (m1)-[:TRANSFERRED {amount:8000, at:datetime('2026-03-20T14:08')}]->(m3),
       (m2)-[:TRANSFERRED {amount:4500, at:datetime('2026-03-20T14:20')}]->(m4),
       (m2)-[:TRANSFERRED {amount:4400, at:datetime('2026-03-20T14:22')}]->(m5),
       (m3)-[:TRANSFERRED {amount:7900, at:datetime('2026-03-20T14:31')}]->(m6),
       (m5)-[:CASHED_OUT  {amount:4400, at:datetime('2026-03-20T15:02')}]->(atm);

// B2. Old rule: first receiving account only. Expect R3,000.
MATCH (:Victim {id:'V-1'})-[:PAID]->(first:Account)
RETURN first.id, first.balance AS recoverable;

// B3. Walk the tree forwards, time-ordered, after the scam payment.
//     Expect M2, M3 at hop 1 and M4, M5, M6 at hop 2 (M1 is hop 0).
MATCH (:Victim {id:'V-1'})-[pay:PAID]->(m1:Account)
MATCH p = (m1)(()-[t:TRANSFERRED]->()){1,4}(m:Account)
WHERE t[0].at > pay.at
  AND all(i IN range(1, size(t)-1) WHERE t[i].at > t[i-1].at)
RETURN m.id AS account, min(length(p)) AS hops_after_M1, m.balance AS balance
ORDER BY hops_after_M1, account;

// B4. Freeze the whole tree. Expect R15,600 recoverable (78%),
//     and R4,400 already cashed out.
MATCH (:Victim {id:'V-1'})-[pay:PAID]->(m1:Account)
OPTIONAL MATCH (m1)(()-[t:TRANSFERRED]->()){1,4}(m:Account)
WHERE t[0].at > pay.at
  AND all(i IN range(1, size(t)-1) WHERE t[i].at > t[i-1].at)
WITH pay, m1, collect(DISTINCT m) AS downstream
WITH pay, downstream + m1 AS tree
UNWIND tree AS acc
OPTIONAL MATCH (acc)-[c:CASHED_OUT]->(:CashPoint)
WITH pay.amount AS paid,
     sum(acc.balance) AS recoverable,
     sum(coalesce(c.amount, 0)) AS cashed_out
RETURN paid, recoverable, round(100.0 * recoverable / paid) AS pct, cashed_out;

// B5. One mule, two victims: who else paid into this tree?
//     Expect V-1 -> M1 and V-2 -> M3.
MATCH (:Victim {id:'V-1'})-[pay:PAID]->(m1:Account)
OPTIONAL MATCH (m1)(()-[t:TRANSFERRED]->()){1,4}(m:Account)
WHERE t[0].at > pay.at
WITH m1, collect(DISTINCT m) AS downstream
WITH downstream + m1 AS tree
UNWIND tree AS acc
MATCH (v:Victim)-[:PAID]->(acc)
RETURN v.id AS victim, acc.id AS paid_into
ORDER BY victim;

// =====================================================================
// C. COLLUSION RING (food-delivery pattern, like iFood's)
// =====================================================================

// C1. Customers, restaurants, couriers and the devices they log in from
UNWIND ['user-1','user-2','user-3','user-4','user-5','user-6','user-7'] AS id
CREATE (:Customer {id: id});
UNWIND ['Rest. A','Rest. B','Rest. C'] AS name
CREATE (:Restaurant {name: name});
UNWIND ['courier-1','courier-2'] AS id
CREATE (:Courier {id: id});
UNWIND ['phone-1','tablet-2'] AS id
CREATE (:Device {id: id});

UNWIND [['user-1','Rest. A',9],['user-2','Rest. A',11],['user-3','Rest. A',8],['user-4','Rest. A',9],
        ['user-7','Rest. A',1],['user-7','Rest. B',2],['user-5','Rest. B',3],['user-5','Rest. C',2],
        ['user-6','Rest. C',4]] AS row
MATCH (c:Customer {id: row[0]}), (r:Restaurant {name: row[1]})
CREATE (c)-[:ORDERED {orders: row[2]}]->(r);

UNWIND [['courier-1','Rest. A'],['courier-2','Rest. B'],['courier-2','Rest. C']] AS row
MATCH (c:Courier {id: row[0]}), (r:Restaurant {name: row[1]})
CREATE (c)-[:DELIVERS_FOR]->(r);

UNWIND [['user-1','phone-1'],['user-2','phone-1'],['user-3','phone-1'],['user-4','phone-1'],
        ['courier-1','phone-1'],['user-5','tablet-2'],['user-6','tablet-2']] AS row
MATCH (a {id: row[0]}), (d:Device {id: row[1]})
WHERE a:Customer OR a:Courier
CREATE (a)-[:USES_DEVICE]->(d);

// C2. Shared devices. Expect phone-1 with 5 users, tablet-2 with 2.
MATCH (a)-[:USES_DEVICE]->(d:Device)
WITH d, collect(a.id) AS users
WHERE size(users) > 1
RETURN d.id AS device, users;

// C3. Score each shared device (plain Cypher, works on Aura Free).
//     Expect phone-1: 5 accounts, 37 orders, 100% to Rest. A,
//                     courier on the same phone -> flag for review
//            tablet-2: 2 accounts, 9 orders, 67% to Rest. C -> household
MATCH (d:Device)<-[:USES_DEVICE]-(a)
WITH d, collect(a) AS users
WHERE size(users) > 1
UNWIND users AS u
MATCH (u)-[o:ORDERED]->(r:Restaurant)
WITH d, users, r, sum(o.orders) AS n
WITH d, users, collect([r.name, n]) AS spread, sum(n) AS total
WITH d, users, total,
     reduce(best = ['', 0], x IN spread | CASE WHEN x[1] > best[1] THEN x ELSE best END) AS top
RETURN d.id AS device,
       size(users) AS accounts,
       total AS orders,
       top[0] AS top_restaurant,
       round(100.0 * top[1] / total) AS pct_to_top,
       EXISTS {
         MATCH (c:Courier)-[:USES_DEVICE]->(d)
         MATCH (c)-[:DELIVERS_FOR]->(:Restaurant {name: top[0]})
       } AS courier_on_same_device
ORDER BY pct_to_top DESC;

// C4. GDS: community detection (Louvain).
//     Expect two communities:
//       {user-1..4, courier-1, phone-1, Rest. A}
//       {user-5, user-6, user-7, courier-2, tablet-2, Rest. B, Rest. C}
MATCH (a)-[r:ORDERED|DELIVERS_FOR|USES_DEVICE]->(b)
WITH gds.graph.project('orders', a, b, {}, {undirectedRelationshipTypes: ['*']}) AS g
RETURN g.graphName, g.nodeCount, g.relationshipCount;

CALL gds.louvain.stream('orders')
YIELD nodeId, communityId
WITH communityId, collect(coalesce(gds.util.asNode(nodeId).id,
                                   gds.util.asNode(nodeId).name)) AS members
RETURN communityId, size(members) AS size, members
ORDER BY size DESC;

CALL gds.graph.drop('orders', false);

// =====================================================================
// PORTFOLIO STARTER: PaySim
// PaySim (Lopez-Rojas et al., 2016) is a synthetic mobile-money dataset
// generated from one month of logs of a mobile-money service in an
// African country. It's on Kaggle. Load a sample as
// (:Account)-[:TRANSFERRED {amount, step}]->(:Account), plant a few rings
// and mule trees with the patterns above, and show your queries find
// them while ordinary transfers pass.
// =====================================================================