A few months ago, at 03:12 WAT, an account at one of the institutions on our network pushed a TRANSFER of ₦4,250,000 from a device it had never used before, on a datacenter IP. Our screening stack blocked it in 155 milliseconds. That part of the story is table stakes. Every serious fraud platform blocks things.

The interesting part is what happened next. The flagged account had already received money from two other accounts, which had received it from a fourth, which had taken it from a victim forty minutes earlier. By the time a human would have opened their dashboard, the funds would have been two more hops away and converted to something irreversible. Instead, our tracing engine walked the flow backwards and forwards, marked every account in the path, and froze ₦4.1 million at the third hop, automatically, before the money could cash out.

This post is about how that tracing system works: the data model, the traversal, the patterns it matches, and the engineering constraints that shaped every decision.

Transaction tables cannot answer "where did it go?"

Traditional transaction monitoring is row-oriented. Every transfer is evaluated as an independent record against thresholds and rules: amount above X, velocity above Y, country not in Z. This catches naive fraud. It structurally cannot catch organized fraud, because organized fraud is designed so that every individual row looks ordinary.

A mule account is the canonical example. The account holder is real. The KYC documents passed. The account sat dormant or behaved normally for weeks. Then it receives ₦900,000 from four strangers and empties within two hours. No single row in that story trips a threshold. The crime only exists in the shape of the flow: the fan-in, the hold time, the fan-out.

So the first architectural decision was that transactions are not rows. They are edges.

The graph model: two kinds of edges

Everything Finhaq knows about an institution's activity lives in a property graph with a small, deliberate schema. Nodes are entities; edges are relationships. We keep exactly two families of edges, because they answer two different questions:

-- nodes
Account(id, institution_id, kyc_tier, trust_score, status)
Device(fingerprint, os, first_seen, last_seen)
IpAddr(address, asn, is_datacenter, is_vpn, geo)
Phone(number)  -- and Email(address) similarly

-- value edges: "where did the money go?"
TRANSFERRED(from_acct, to_acct, amount, currency, ts, channel, verdict)

-- identity edges: "who is connected to whom?"
LOGGED_IN_WITH(account, device, ts)
CONNECTED_FROM(account, ip, ts)
SHARED_PHONE(account, phone)

Value edges let us trace funds. Identity edges let us explain why a cluster of "unrelated" accounts is actually one operator. A fraud ring almost never shares value edges back to its mastermind, but it constantly shares devices, IPs, and phone numbers internally. Most platforms model only the first family. The second is where the rings live.

Design rule: every node and edge carries a timestamp and a source. Traces must be reconstructable months later for a regulator, which means the graph is append-only. Nothing is ever updated in place. New facts supersede old ones, and the old ones remain queryable.

Tracing is a bounded traversal, not a query

When an account is flagged, whether by a blocked transaction, a trust score collapse, or a manual case, the tracer starts a bounded breadth-first walk from that node:

  • Backwards, to find where the money came from (the victim side).
  • Forwards, to find where it went (the cash-out side).
  • Sideways, one level deep, across identity edges (same device, same IP) to catch accounts that split the flow to stay invisible.
trace(start, direction):
  frontier  = [start]
  visited   = set(start)
  depth     = 0

  while frontier and depth < MAX_DEPTH:            # MAX_DEPTH = 5
    edges = fetch_edges(frontier, direction,
                        within = TRACE_WINDOW,       # 30 days
                        min_amount = amount * 0.05)  # decay floor
    next = []
    for e in edges:
      node = other_end(e)
      if node in visited: continue
      visited.add(node)
      score_hop(node, e)            # hold time, split ratio, velocity
      next.append(node)
    frontier, depth = next, depth + 1

  return subgraph(visited)

Three bounds keep this fast and honest:

  1. Depth limit of five hops. Laundering chains in our data rarely exceed four meaningful hops before the money exits to cash or crypto. Past five, you are generating noise a case officer will never read.
  2. A time window. Edges older than the window are irrelevant to this flow and make the walk explode combinatorially.
  3. Amount decay. Each hop, we ignore edges below 5% of the traced amount. This is what stops the traversal from wandering into the mule's grocery spending.

The result is a subgraph, typically 8 to 40 nodes, produced in a 380ms p50 budget. That number is a hard requirement, not a vanity metric: the trace runs inside the same request path that opens a case, so it competes for the same latency budget as everything else. We hit it with boring engineering: adjacency indexes on (from_acct, ts) and (to_acct, ts), hot accounts cached in memory, and edge fetches batched per depth level rather than per node.

What the matcher looks for in a trace

A raw subgraph is not an answer. The tracer scores the shape of the flow against a small library of structural signatures, each one deterministic and explainable:

  • Fan-in / fan-out: many senders into one account that quickly forwards the aggregate. The classic collector.
  • Rapid pass-through: median holding time under two hours across the chain. Real customers sleep, spend, and save. Pipes don't.
  • Layered chains: three or more sequential hops where each account forwards 80-100% of what it received, within hours.
  • Amount splitting: an inbound sum that leaves as several transfers just below regulatory reporting thresholds: structuring viewed from the graph side rather than the row side.
  • Cycles: funds returning to an earlier node in the chain within 3-5 hops, a layering pattern that exists only to dirty the trail. We detect these with a depth-limited DFS over the traced subgraph, normalized so each cycle is reported once.
  • Identity overlap: two accounts in the chain that share a device, an IP, or a phone number. This is the signature that upgrades "suspicious flow" to "one operator."

Every signature that matches becomes a labeled annotation on the subgraph. Nothing about this is a black box, and that is a deliberate product decision, not a limitation.

Why there is no neural network in the decision path

We evaluated graph ML early, and it does things our deterministic matcher cannot: it generalizes to laundering shapes nobody has written a signature for. We still rejected it for the blocking path, for one reason that outweighs everything else: a compliance officer has to explain every hop to a regulator. "The model scored this cluster 0.94" is not an explanation that survives an examination. "These four accounts forwarded 97% of inbound funds within 90 minutes and share device fingerprint d8f2…" is.

So the architecture is: deterministic signatures decide, machine learning advises. Models run off to the side, ranking which traces deserve human attention first. They can reorder a queue. They cannot freeze an account.

From trace to action

A trace that doesn't change anything is a report. Ours terminates in three automatic actions:

  1. Freeze at the destination. Accounts on the forward path that still hold traced funds are frozen, starting from the furthest hop and walking back. Freezing the furthest point first matters: it is where the money still is.
  2. Case assembly. The subgraph, matched signatures, hold times, and every underlying transaction are packaged into a case with the evidence already attached. The officer's job is judgement, not data entry.
  3. Network notification. If the chain crosses into another institution on the Finhaq network, that institution's instance is alerted with the hop details that concern its accounts. A mule identified anywhere becomes unbankable everywhere on the network. Over time, that is the actual deterrent.

What this costs, and what it buys

The honest price of this design is storage and write amplification: every transaction writes edges, every login writes identity edges, and the graph grows forever. We accept it because the append-only property is exactly what makes the system auditable: the same property that lets us answer an examiner's "show me what you knew in March" without a archaeology project.

What it buys is the difference between detecting fraud and recovering from it. A verdict tells you a transaction was bad. A trace tells you where the money is, who helped move it, and what to freeze so the victim gets made whole. In the incident from the opening paragraph, that difference was ₦4.1 million recovered instead of written off.


Next in the series: how those 155 milliseconds are spent: the six-engine screening pipeline that produces every verdict. Read part 2.

See a live trace on your own data

We'll run the tracer against a scenario from your institution in a 30-minute demo: blocked transaction, full fund trace, frozen destination, packaged case.