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.
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.
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#
- Ingest a bulk capture (CSV in the shipped build) and reject a malformed one with an actionable message.
- Score every transaction with a trained model, and report a calibrated probability, not a raw margin.
- Explain every alert with the observations that raised it, and show the rule-engine reasons next to them.
- Resolve entities: group addresses under one controller and link IPs to wallets.
- Export a ranked alert list as JSONL and browse it interactively, in a browser or on a headless box.
- Install and run with no network access.
Non-functional requirements#
- NFR1, causality. No feature may use information from a later transaction.
- NFR2, honest evaluation. Splits must respect time and cases; ground truth must not leak into features.
- NFR3, offline. Runtime makes no network request, and a missing dependency fails the install loudly.
- NFR4, false positives on lawful twins. Legitimate look-alikes such as exchange batches and privacy CoinJoins must not be flagged wholesale.
- NFR5, reproducibility. The model is a build artifact that can be rebuilt in about a minute, and a drift check catches a stale one.
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 interface#
The interface is the command line plus a small local HTTP API. These are the real commands and routes.
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.
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.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.
- The analyst runs
chainwatch ingest capture.csv. - 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.
- Features are built for the whole capture, with an optional history file seeding the state.
- The frozen booster and calibrator give a probability per row.
- 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.
- Attributions become sentences with the feature value filled in.
- The rule reasons and the fired rule ids are copied from the engine.
- 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.
- The scored capture becomes a
Sessionwith aggregates over all rows. - The server exposes paged rows, one alert, the graph and the cases.
- The REPL calls the same session functions in-process.
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.
- On a connected machine
make_wheelhouse.shdownloads binary wheels only, for the target platform. - The whole directory is copied across.
install.shcreates a venv and installs with--no-index, then runscheck.bootstrapbuilds the model andstartserves 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.
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.
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.
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.
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.
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%.
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 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.
- The analyst loads a CSV; the engine reads the file once.
- The schema gate checks the columns.
- One pass builds the causal features, seeded by an optional history file.
- The frozen booster and calibrator produce a probability per row, and the frozen threshold flags rows.
- TreeSHAP and the rule reasons are rendered for the flagged rows.
- The ranked list is written to
alerts.jsonland the full scored capture becomes a session. - The console or REPL pages through that session.
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?
- It is faster to batch updates at the end.
- Otherwise the row would see itself, and any later reader of the state would also leak information backwards.
- XGBoost requires features to be computed after labels.
- The wallet registry is only loaded at the end.
A transaction has five inputs and three outputs of exactly 0.1 BTC. What does Safe-CIOH do?
- Merges all five inputs into one entity.
- Drops the transaction from the graph.
- Treats it as a CoinJoin fingerprint and keeps the inputs as separate entities.
- Flags it as illicit.
Why compare models at a fixed false-positive budget?
- A model could look better only by spending more false positives, so cost has to be held equal.
- XGBoost cannot output probabilities otherwise.
- The calibrator needs the budget to fit.
- It makes the neural networks faster.
A graph neural network with its adjacency set to zero scored higher than the same network with edges. What does that say?
- The GNN is broken.
- The graph is the most important feature.
- Seeds do not matter.
- On this temporally shifting data, message passing was a liability, and the ablation is the control that shows it.
What makes install.sh safe for an air-gapped machine?
- It downloads wheels from PyPI at the last second.
- It installs with
--no-indexfrom a vendored wheel folder, so a missing wheel is an error, not a silent fetch. - It disables Python's import system.
- It only works on the build machine.
--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?
- To store the visitor's location.
- To rate-limit the map page.
- To let exactly one call per visitor per day succeed across all serverless instances, so only one push is sent.
- To encrypt the address.