Simiyon Vinscent Samuel · Engineering

All posts / Python

  • Python
  • XGBoost
  • Bitcoin
  • SIH 2026
  • Offline
  • Redis

ChainWatch: offline, explainable anomaly detection on Bitcoin transaction traffic for SIH 2026

How team Hashcrofts built ChainWatch for the NTRO problem statement SIH26146, and what I built inside it: a causal feature pipeline, a calibrated XGBoost model with SHAP reasons, Safe-CIOH entity clustering, an alerts.jsonl pipeline, an offline wheelhouse installer and a Redis-backed telemetry map.

ChainWatch is our Smart India Hackathon 2026 entry for the NTRO problem statement SIH26146, "AI-Powered Monitoring & Analysis of Bitcoin Transaction Traffic". It is a team project from team Hashcrofts, and this post walks through the parts of it I built: a Python command line and installer that runs fully offline, a transaction graph and entity clustering layer, a calibrated XGBoost anomaly model with per-alert explanations, an alerts file, and a small Redis-backed telemetry page on the project site. I will also say plainly which parts of the live demo are the rule engine and which parts only exist in the local build.

Two systems share a repo, and only one of them touches transactions. The numbered badges follow one capture from file to ranked alert. The bottom band is the telemetry path of the public website; it never sees a transaction. The dashed edges are views over the same scored session.

Who built what#

ChainWatch was registered on the portal as team HashCrofts, and the team deck (idea title, technical approach, feasibility, impact) is the shared design document. The repository history is short and I will only claim what it shows. The first commit, the console user interface, is from Isha Venkataraman. The remaining 39 commits are mine (38 under my GitHub handle and one under a local username): the synthetic data generator fixes, the feature pipeline, model training and comparison, the alert renderer, the package, the command line, the terminal console, the offline installer and the website telemetry. The rule engine, the 27-typology data design and the deck content came out of the team's shared planning, and I do not have commit evidence for who wrote which slide.

One capture, end to end#

Before the parts, here is the path a single file takes. The animation below uses the real command names; the notes show how the state of each stage changes.

Every front end reads the same scored object. The browser console and the terminal REPL both call the same engine, so neither one owns any part of the decision.
The ChainWatch console overview after replaying the sample capture: transaction counters, daily activity chart and a live event stream with scored alerts
The public console after replaying its bundled sample. This is the hosted demo, which runs the transparent rule engine over 2,748 synthetic transactions. The trained model lives in the local build, and the gap between the two is the reason the model exists: the rules reach 99.8% precision and 45.2% recall here.

Understand the problem#

The problem statement asks for monitoring and analysis of Bitcoin transaction traffic that links what the network sees with what the chain records, and produces something an investigator can act on. I turned that into these requirements.

Functional requirements#

  1. Ingest a bulk capture (CSV in the shipped build) and reject a malformed one with an actionable message.
  2. Score every transaction with a trained model, and report a calibrated probability, not a raw margin.
  3. Explain every alert with the observations that raised it, and show the rule-engine reasons next to them.
  4. Resolve entities: group addresses under one controller and link IPs to wallets.
  5. Export a ranked alert list as JSONL and browse it interactively, in a browser or on a headless box.
  6. Install and run with no network access.

Non-functional requirements#

  1. NFR1, causality. No feature may use information from a later transaction.
  2. NFR2, honest evaluation. Splits must respect time and cases; ground truth must not leak into features.
  3. NFR3, offline. Runtime makes no network request, and a missing dependency fails the install loudly.
  4. NFR4, false positives on lawful twins. Legitimate look-alikes such as exchange batches and privacy CoinJoins must not be flagged wholesale.
  5. NFR5, reproducibility. The model is a build artifact that can be rebuilt in about a minute, and a drift check catches a stale one.
On the board so far
Requirements: 6 functional, 5 non-functional
Inputs : CSV capture, 96 columns, optional wallet registry
Outputs: ranked alerts.jsonl, browser console, terminal REPL
Rules  : causal features only, offline at runtime, lawful twins must stay quiet

The set-up#

Core entities#

Five things carry the whole system: the transaction (one row), the wallet (an entity with a role), the case (a chain of related transactions with a typology), the alert (a flagged transaction with reasons) and the model bundle (booster, calibrator, feature order and threshold).

The transaction is the hub. Wallets and the network sighting are what the cross-layer correlation joins, and the case chain is the ground truth that the synthetic data carries so accuracy can be measured.

The interface#

The interface is the command line plus a small local HTTP API. These are the real commands and routes.

Bash
chainwatch bootstrap          # generate the dataset if missing, then train (about a minute)
chainwatch ingest capture.csv # validate against the schema, stage output/ingested.csv
chainwatch analyze            # score the staged file, write alerts.jsonl
chainwatch score capture.csv --out alerts.jsonl --history prior.csv
chainwatch start --port 8080  # browser console on localhost
chainwatch tui                # interactive REPL; with a csv it prints a one-shot report
chainwatch check              # readiness, including model and code drift

GET  /api/health
POST /api/score                       # upload a CSV, returns a session id
GET  /api/session/{sid}/summary
GET  /api/session/{sid}/rows          # paged, sortable, filterable
GET  /api/session/{sid}/alert/{row}
GET  /api/session/{sid}/graph
GET  /api/session/{sid}/cases
GET  /api/session/{sid}/case/{chain_id}

The data model#

The input contract is the 96-column record from the deck. I grouped its columns into the five families that matter for the rest of the post. The table figure shows the families, not every column.

Ground truth is optional by design. A real capture has no true_label, so scoring works without it and accuracy reporting is skipped. The wallet registry travels with the model, because the taint features mean nothing without it.
The metrics come from files in the repo. The model metadata is stored in models/spec.json and the comparison tables in docs/ml-findings.md.

Build it one requirement at a time#

Each board below keeps what earlier steps built in grey and glows the new parts.

1. Ingest a capture and reject a bad one#

The first requirement is the dull one that decides whether the rest can be trusted. schema.validate compares the file with the published column list and raises a SchemaError that names the missing columns, instead of failing later inside the feature builder with a KeyError about a column the analyst never heard of. chainwatch ingest then stages output/ingested.csv and prints the time range, the number of distinct wallets and the number of case chains.

One gate, one message. A rejected file never reaches the model.
  1. The analyst runs chainwatch ingest capture.csv.
  2. A passing file is staged and summarised; a failing one stops with the names of the missing columns.

2. Score every transaction#

Staged data goes through engine.score. The feature builder produces 114 columns, the code checks that their order equals the frozen feature list, and only then calls the booster. That check exists because a shifted column makes every tree read the wrong input and produces plausible, wrong scores without an exception. Categorical codes are frozen with the model for the same reason. The calibrated probability is compared with a frozen threshold chosen at the false-positive budget of the rule engine.

The model is a frozen artifact. Nothing is fitted at request time, because an upload has no labels.
  1. Features are built for the whole capture, with an optional history file seeding the state.
  2. The frozen booster and calibrator give a probability per row.
  3. The wallet registry, when present, feeds the taint features; without it they fall back to "no watchlist".

3. Explain every alert#

A probability alone does not satisfy "why was this flagged". For each flagged row, up to 4,000, engine.score asks the booster for exact TreeSHAP contributions, takes the five largest positive ones and renders each through a table of plain-language templates in ml/alerts.py, for example "Funds trace back to a watchlisted service 2 hop(s) away" or "Origin IP has been used by 6 distinct wallets". Every alert then carries three sources that can be checked against each other: the rule engine's reasons, the model's attributions and the raw network evidence.

Three witnesses per alert. If the model and the rules disagree, the analyst sees both and can argue with either.
  1. Attributions become sentences with the feature value filled in.
  2. The rule reasons and the fired rule ids are copied from the engine.
  3. The origin IP, ASN, ASN category and country are attached as evidence, then the list is ranked by risk.

4. Browse it, in a browser or over SSH#

The browser console is a single HTML template that the FastAPI server serves. The scored capture stays in memory as a Session, and the console pages through it with /rows, so the KPI tiles are computed over every row, not over the 12,000-row sample the page can embed. I made that choice after an earlier sample skewed the alert rate and the page showed a recall number that came from the sample, not the file. The terminal REPL, started with chainwatch tui, is an msfconsole-style prompt: use a sample, filter risk>80, alerts 5, wallet <id>, case MIX-001, serve 8099, export.

One truth, two views. The REPL can also start the web console on the session it holds, so the two never disagree.
  1. The scored capture becomes a Session with aggregates over all rows.
  2. The server exposes paged rows, one alert, the graph and the cases.
  3. The REPL calls the same session functions in-process.
The alert queue listing laundering cases ranked by peak risk with hops, jurisdictions and real-world precedent
The ranked alert queue. Cases are ordered by peak risk, each with its pattern, hop count, jurisdictions and the real-world precedent the typology is modelled on. Data is synthetic.

5. Install and run with no network#

The last requirement is the one that is easy to claim and hard to prove. There are two installers on purpose. get.sh is a curl | bash path for a connected machine that fetches the latest release, creates a venv and optionally takes a pretrained model from a release asset. For an air-gapped box, make_wheelhouse.sh downloads wheels for the target platform into vendor/, and install.sh installs with --no-index so a missing wheel fails loudly instead of quietly reaching PyPI.

Fail at install, not in the demo. The bundle is built for the target platform and Python version, and the console has no runtime fetch.
  1. On a connected machine make_wheelhouse.sh downloads binary wheels only, for the target platform.
  2. The whole directory is copied across.
  3. install.sh creates a venv and installs with --no-index, then runs check.
  4. bootstrap builds the model and start serves the console.

Deep dive 1: keeping features causal#

Group by wallet, take a rolling window

A pandas rolling window over a sorted frame is short to write and easy to get wrong. It can include the current row, it ignores the wallet's earlier state across a split, and a cross-wallet feature such as "how many wallets used this IP" needs a second pass that is easy to leak through.

A second pass for cross features

Compute per-wallet features with rolling windows and cross features with a separate lookup. It works, but the ordering between the two passes is an unwritten rule, and a reviewer cannot see it.

One loop: read the past, emit, then update

Walk the rows once in time order. For row i, read the running state of the sender, receiver, pair and IP, write the features, and only then add row i to that state. Causality becomes a property of the code layout, and it can be checked with a brute-force recomputation. I chose this one, and ml/tests/test_causality.py compares it with the brute force.

Causality by layout. The funder-provenance update has to sit after the sender counts; placing it before them under-counted by one, which the brute-force test caught.

Deep dive 2: choosing and testing the model#

Report accuracy

With 15.9% positives, accuracy hides everything, and a random split lets the model memorise wallets and cases.

Precision and recall on a temporal split

Better, but a model can still win by spending more false positives than the rules. A fair comparison fixes the cost.

A fixed false-positive budget and a re-thresholded baseline

Every model is held to the 844 false positives the rule engine produces at threshold 50, on a time-ordered, case-aware split. The rule engine is also re-run at threshold 30 to answer the obvious challenge, "did you just lower the bar?". At 30 it recovers 734 of the 3,096 rows it misses and costs 9,244 false positives. XGBoost recovers 3,044 at 845.

Every tree ensemble beats every neural network. XGBoost ties RandomForest and wins on fit time, native class weighting and exact TreeSHAP, which the explanations need.

Two more checks mattered more than the leaderboard. First, a hyperparameter search of 40 Optuna trials over rolling-origin folds inside the training window moved test PR-AUC only from 0.997 to 0.999: the problem is not hyperparameter-limited. Second, removing every attribution-style feature (watchlist, taint, VASP flags, country) still left 88 behavioural features at PR-AUC 0.988, so the model is not a lookup table.

The graph made the network worse, and more edges made it worse again. Identical architecture and schedule with a zero adjacency is the decisive control.

Deep dive 3: the transaction graph and Safe-CIOH#

Union every input address of every transaction

It is the textbook heuristic. A CoinJoin with several participants then merges strangers into one entity, and one mixing round can glue half the graph together.

Skip transactions with many inputs

This avoids the giant merge but also discards a lot of real consolidation, where one owner really did spend many inputs.

Look at the outputs and skip only the CoinJoin fingerprint

If three or more outputs share an identical satoshi amount, the transaction is a CoinJoin fingerprint (Whirlpool, Wasabi and similar), so its inputs stay as separate singletons. Everything else is merged with a union-find using path compression and union by rank. This is the Safe-CIOH in chainwatch/engine.py, with a unit test next to it.

The guard is three lines of output inspection. Without it, one mixing round collapses unrelated users into one entity.

I measured the result honestly instead of tuning the data until it looked good. On the synthetic data the clustering has 100% precision and 10.8% recall against wallet identity, with a measured ceiling of 36.2%, because merging separate co-spend links needs an address that is spent more than once and modern wallets do not reuse addresses. I kept the reuse knob at its realistic low setting. That weakness is exactly where the network layer earns its place: IP co-occurrence resolves operators at 83.8% precision on traffic from an operator's own ISP, against 0.1% on shared VPN, Tor and app exits. I pitch the network layer as attribution, not classification, because removing it barely moves detection.

The entity network view: wallets as dots coloured by risk, exchange squares and watchlist rings, with a reading guide
The entity network. Dots are wallets coloured by risk, squares are registered exchanges and dashed rings are watchlist entries. In the Python server the graph endpoint returns the highest-risk wallets plus their direct counterparties and reports "shown of total" instead of implying the whole graph.

Deep dive 4: lawful look-alikes and CoinJoin#

Flag the CoinJoin shape

High recall, and a large share of lawful privacy users flagged. On the first version 42.3% of lawful Whirlpool rows were false positives.

Trust the watchlist

Taint from a watchlisted service is a strong signal, but only 61% of darknet markets in the data are watchlisted, so about half the CoinJoin laundering has no watchlist provenance at all.

Ask who funded the wallet, and price the mistake

Behavioural funder-provenance features ask whether the wallet's funder acted like a service (hundreds of counterparties) rather than whether anyone had listed it, and clean CoinJoin false positives get a 10x cost in training. The Whirlpool false-positive rate fell from 42.3% to 12.8% while laundering recall held near 98.5%.

Lawful twins are measured on their own. They are reported per twin instead of being averaged into precision, because not firing on the lawful look-alike is the design premise.

What the residual costs is not a modelling question. Using a privacy tool is lawful, so how much of that remainder to accept is a policy choice, and the findings document says so rather than leaving it to a loss function.

Deep dive 5: the alerts file and the telemetry page#

This is the part where the word "alerts" means two different things, and I want to keep them apart. The transaction alerts are the ranked JSONL file: chainwatch analyze and chainwatch score write one JSON object per line, and the REPL's export command writes the same shape. The push notification belongs to the project website, not to the detector: when a first-time human visitor opens the public site, a serverless route sends a mobile push through ntfy.

A module-level Map of seen IPs

It is what I wrote first, and it is still the fallback. On Vercel every cold instance starts empty, so the same visitor triggers a push from every instance.

A JSON file in /tmp

Writable on Vercel, but per instance and wiped on redeploy. It also cannot arbitrate between two instances at once.

One atomic Redis command

An Upstash Redis SET key 1 EX 86400 NX succeeds for exactly one caller per day across all instances and deploys, and that caller sends the push. Visitor records go on a Redis list with LPUSH and are trimmed to 5,000 entries, and the map page reads the newest 500 with LRANGE.

The cooldown is the single source of truth. If Redis is unreachable the route falls back to a per-instance cache and a file in /tmp, so a Redis outage degrades the dedupe rather than breaking the page.

The final design#

Put together, the local product is a causal feature pass, a frozen calibrated model, an explanation layer, a session, two front ends and an offline install path. The website is a separate Next.js app: a route that serves the console HTML from disk without caching, the telemetry beacon and the Redis-backed map.

One request, one rebuild. The walk below follows a capture through the numbered path.
  1. The analyst loads a CSV; the engine reads the file once.
  2. The schema gate checks the columns.
  3. One pass builds the causal features, seeded by an optional history file.
  4. The frozen booster and calibrator produce a probability per row, and the frozen threshold flags rows.
  5. TreeSHAP and the rule reasons are rendered for the flagged rows.
  6. The ranked list is written to alerts.jsonl and the full scored capture becomes a session.
  7. The console or REPL pages through that session.
The shipped design
Capture -> schema gate -> causal features (114) -> frozen XGBoost + isotonic
        -> TreeSHAP + rule reasons -> alerts.jsonl + Session -> console / REPL
Offline : vendor/ wheels, pip --no-index, fonts + d3-sankey inlined
Website : Next.js, beacon -> Redis cooldown -> ntfy, Leaflet map

What I would change#

  • Real data. Everything is synthetic. I would validate against public labelled data and report the gap before claiming anything about real Bitcoin.
  • Two engines, one story. The hosted demo shows the rule engine and the trained model lives in the local build. They should share one scoring path so the public demo is the product.
  • Package the research code once. The runtime has a packaged copy of six ml/ modules, and keeping the copies in sync is a manual job that the drift check only partly covers.
  • The inductive split. It drops 65,592 rows and skews the base rate to 49%, so I use an operator-disjoint split for the unseen-actor question. I would design the split first next time.
  • Privacy of the telemetry. Hash or truncate visitor addresses and add a retention limit.

Test yourself#

Why does the feature loop update a wallet's state only after emitting the row's features?

  1. It is faster to batch updates at the end.
  2. Otherwise the row would see itself, and any later reader of the state would also leak information backwards.
  3. XGBoost requires features to be computed after labels.
  4. The wallet registry is only loaded at the end.
B. Reading the past before adding the current row keeps every feature strictly causal, and a brute-force recomputation in the tests verifies it.

A transaction has five inputs and three outputs of exactly 0.1 BTC. What does Safe-CIOH do?

  1. Merges all five inputs into one entity.
  2. Drops the transaction from the graph.
  3. Treats it as a CoinJoin fingerprint and keeps the inputs as separate entities.
  4. Flags it as illicit.
C. Three or more identical output amounts are the CoinJoin signature, so merging inputs would join strangers.

Why compare models at a fixed false-positive budget?

  1. A model could look better only by spending more false positives, so cost has to be held equal.
  2. XGBoost cannot output probabilities otherwise.
  3. The calibrator needs the budget to fit.
  4. It makes the neural networks faster.
A. The rule engine's 844 false positives at threshold 50 set the budget, and the re-thresholded rule engine shows what simply lowering the bar costs.

A graph neural network with its adjacency set to zero scored higher than the same network with edges. What does that say?

  1. The GNN is broken.
  2. The graph is the most important feature.
  3. Seeds do not matter.
  4. On this temporally shifting data, message passing was a liability, and the ablation is the control that shows it.
D. The ablated network won on four of four seeds, which independently matches published findings on graph structure under temporal shift.

What makes install.sh safe for an air-gapped machine?

  1. It downloads wheels from PyPI at the last second.
  2. It installs with --no-index from a vendored wheel folder, so a missing wheel is an error, not a silent fetch.
  3. It disables Python's import system.
  4. It only works on the build machine.
B. --no-index is the point: failing at install is better than discovering a hidden network dependency during a demo.

Why does the telemetry route use SET NX EX 86400 in Redis?

  1. To store the visitor's location.
  2. To rate-limit the map page.
  3. To let exactly one call per visitor per day succeed across all serverless instances, so only one push is sent.
  4. To encrypt the address.
C. A per-instance map cannot dedupe across cold starts, while an atomic Redis command can.