Skip to content
Anish-185Public

About

Offline, air-gapped Bitcoin transaction-traffic intelligence for SIH 2026 (PS 26146, NTRO). Fuses P2P relay captures with on-chain data to infer transaction origin with calibrated confidence, abstains where attribution is provably impossible, and produces tamper-evident investigator packets.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

btc-intel console: Follow the coin back to the hand that sent it.

btc-intel

Follow the coin back to the hand that sent it.

Offline Bitcoin forensics for investigators. It clusters wallets into owners, flags laundering, traces money to where it's cashed out, and estimates which IP address first broadcast a transaction. When the evidence can't support an answer, it says so.

SIH 2026 Air-gapped Python React License: MIT

Watch the 40-second demo · How it works · Screenshots · Quick start · Docs


▶ 40 seconds of btc-intel

Play the 40-second btc-intel demo video

Click to play (docs/media/btc-intel-40s.mp4, 1080p, 40 s). Offline ingest → verdicts → cash-out trace → live red-team attack → chain of custody.


◼ What it is

Every Bitcoin payment is broadcast from a real IP address, and every laundering operation leaves a shape in the transaction graph. btc-intel joins those two layers, the network and the chain, into one investigator console that runs entirely on an air-gapped machine.

Give it NTRO's raw transaction dumps (CSV, JSON or XML) and you get back:

◼ A ranked alert queue Entities scored by four detectors and a graph network, fused into one explained risk score
◼ Owners, not addresses Wallets clustered into entities with BlockSci's heuristics, with CoinJoin traps and a collapse guard
◼ The likely sender Origin IP estimates from P2P relay timing, discounting Tor, VPN and public relays instead of blaming them
◼ The cash-out Funds traced forward under haircut and poison taint to the exchange clusters where they leave
◼ Evidence that holds up A hash-chained chain-of-custody ledger (ISO/IEC 27037 style) and one-page PDF case reports
◼ An attack button A live red-team mode: inject a laundering pattern and watch whether the stack catches it, with nothing retrained

Important

Calibrate or stay silent. Every IP score is a probabilistic lead, never an attribution. The system abstains rather than name a Tor user or a shared relay, and every number in this repo comes from one command (python -m eval.report) and is quoted with its condition in docs/CLAIMS.md.


◼ How it works

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#1646f5','primaryTextColor':'#f2f5ff','primaryBorderColor':'#0b2fc0','lineColor':'#7f9bd8','secondaryColor':'#07124a','tertiaryColor':'#f1f4fc','fontFamily':'monospace'}}}%%
flowchart LR
    A["Raw dumps<br/>CSV · JSON · XML"] --> B["Ingest<br/>validate · quarantine · GeoIP"]
    B --> C["Graph<br/>wallet clustering"]
    C --> D["Features"]
    D --> E1["Rules"]
    D --> E2["Anomaly"]
    D --> E3["GNN"]
    C --> E4["Taint"]
    B --> P["P2P relays<br/>origin estimation"]
    E1 & E2 & E3 & E4 --> F["Fusion<br/>stacker + SHAP reasons"]
    F --> Q["Alert queue"]
    P --> L["Attribution leads<br/>(IP ↔ cluster)"]
    L --> Q
    Q --> X["Exit points<br/>trace to cash-out"]
    Q --> K["Custody ledger<br/>+ PDF case report"]
Loading

Seven layers, each replaceable and each tested on its own: ingest → graph → features → engines (rules, anomaly, GNN, correlation) → fusion → API → console. The fusion stacker is a non-negative logistic regression because its coefficients are part of the deliverable, and its SHAP values are exact. The full walk-through of every layer is in the deep dive below.


◼ The console

Case overview
This case: entities, alerts and where to start
Alert queue
Alert queue: ranked, with a plain-English reason per row
Entity page
Entity: every engine's score, the reason and the evidence
Investigation graph
Investigation graph: wallets, transactions and broadcast IPs
Red team origin estimation
Red team: true origin vs estimate for injected broadcasts
Trace to cash-out
Exit points: where traced value was cashed out

The console uses the project's cyanotype identity: one cobalt accent that means "interactive, or ours" and never severity; Geist-style prose with every measured value set in mono; and a graph field that stays dark in both themes. See docs/design/design_analysis.md.


◼ By the numbers

5 of 5

illicit operations detected
(4 of 4 on the shifted set)

0.934

alert precision
(0.943 shifted)

0 / 949

false alerts on a dataset
with nothing planted

20 of 30

live red-team injections caught,
nothing retrained

All figures are on simulated generator data: they describe our simulator, not Bitcoin. Source and exact wording: docs/CLAIMS.md (D1, D2, D3, D5).


◼ Quick start

Requires Python 3.11 and uv.

make setup         # uv sync --extra dev
make test          # pytest
make run-pipeline  # end-to-end offline run

Then serve the API and the built console together:

uvicorn api.app:app --host 127.0.0.1 --port 8000   # console at http://127.0.0.1:8000/app/

Stage demo: scripts/demo.sh --preflight, then scripts/demo.sh --pause (docs/DEMO_SCRIPT.md).

◼ Runs on an air-gapped laptop

No network calls at any stage (offline: true in config.yaml). Build a wheelhouse while online, carry it across, install with ./offline/install.sh and run with ./offline/run_offline.sh: one process, no npm, no second server. A kernel-namespace check proves it: unshare -rn sh -c 'ip link set lo up; ./offline/airgap_check.sh' runs 26 checks with nothing but loopback. Details under Running it offline below.


◼ Deep dive

Every layer, its command and its caveats. Click to expand.

Repository layout
Path What lives here
generator/ Synthetic transaction/case data generator
ingest/ Load + normalise raw dumps into the canonical schema
graph/ Address clustering, transaction graph construction
features/ Feature extraction over the graph and tx tables
engines/rules/ Deterministic heuristics (peeling chains, mixers, dust)
engines/anomaly/ Unsupervised outlier detection
engines/gnn/ Graph neural network scoring
engines/correlation/ Cross-case / entity correlation
fusion/ Combines engine scores into one risk score; incremental.py re-runs it over new transactions only
intel/ Offline attribution tag store: sealed bundles, OFAC/CSV/demo importers, propagation (docs/TAGSTORE.md)
api/ Local HTTP API: alerts, graph, live monitoring, red-team injection
custody.py Hash-chained chain-of-custody ledger
web/ Local UI
eval/ Metrics, benchmarks, ground-truth comparison
offline/ Pipeline orchestration, air-gapped packaging
scripts/ The one-command stage demo (demo.sh) and its pinned artifact hashes
vendor/ Cloned reference repos — read-only, never imported (see CONTRIBUTING.md)
tests/ Our tests
data/ raw/, processed/, geoip/, tags/ — gitignored
docs/ Design notes
Generating data
python -m generator.main --n-actors 5000 --n-transactions 200000 \
    --output data/raw/ --formats csv,json,xml --seed 42

Writes transactions.{csv,json,xml} — the same records in NTRO's schema — plus ground_truth.json, which is not part of the public dataset and is read only by eval/. Same seed, byte-identical output.

Typologies (generator/typologies.py): normal (the majority noise class, including innocent NAT/VPN shared IPs), ransomware_collector (victim fan-in then a peeling chain), layering (fan-out/fan-in, several hops), same_actor_cluster (one actor, several wallets, one IP, short window) and coinjoin (equal-value inputs from unrelated wallets — the trap that common-input-ownership clustering must not fall into). Mix and parameters live under generator: in config.yaml.

Each transaction is broadcast across a simulated 500-node P2P network (generator/net.py) with exponential per-hop delays, so one txid appears in several relay rows. --relay-observation-rate sets the fraction of hops that get recorded (default 0.3); --single-row emits one row per txid, for testing the fallback when NTRO's data has no multi-hop records. Some broadcasts are masked behind Tor exits or hosting ASNs — ground truth records both the true and the observed origin IP, so the IP-correlation engine can be scored on whether it discounts those.

generator.inject.inject_pattern(dataset_dir, typology, params, seed) appends one fresh instance of a typology to an existing dataset and updates ground_truth.json — this is what red-team mode calls.

Volume scales with the gossip settings: at defaults, 200k transactions produce ~720k rows (~1.2 GB across all three formats). Turn down gossip.max_hops_per_tx or --relay-observation-rate, or emit fewer formats, if that is too much.

Ingesting
python -m ingest.pipeline --input data/raw/ --output data/processed/transactions.parquet

parse → validate → enrich → parquet. ingest/parsers.py reads CSV, JSON or XML and normalises all three into RawTransaction (ingest/schema.py); field names come from config's schema: map, never from code. Malformed records — bad IP, mismatched input/output array lengths, negative amounts, bad timestamps — land in data/processed/quarantine.parquet with a reason and the original record, rather than being dropped or killing the run.

A directory holding all three formats is read once: pick with --format, or set ingest.format. ground_truth.json is excluded by name, so the labels can never be ingested as evidence.

ingest/geoip.py enriches each row's src_ip from local .mmdb files (paths in config, no network) adding geo_country, asn, asn_org and high_risk_asn. is_high_risk_asn() checks geoip.high_risk_asns — VPN/hosting/Tor-adjacent networks the correlation engine should discount. Missing databases degrade to null columns instead of failing.

Graph and clustering
from graph.builder import from_parquet, iter_transactions, load
from graph.clustering import cluster_wallets
from graph.entity_graph import build_entity_graph

txs = list(iter_transactions(load()))      # relay rows collapsed per txid
g = from_parquet()                          # MultiDiGraph: wallet / transaction / ip
clustering = cluster_wallets(txs)
entities = build_entity_graph(txs, clustering)

graph/builder.py builds a MultiDiGraph with a node_type of wallet, transaction or ip, and edges wallet→tx (input), tx→wallet (output), ip→tx (broadcast); amounts, timestamps and script type ride on the edges.

graph/clustering.py reimplements BlockSci's published heuristics in pure Python — common-input-ownership, and change detection by majority vote over address_reuse, address_type, optimal_change and peeling_chain. Each sub-heuristic returns the outputs it cannot rule out, as BlockSci's do; a tie or too few votes means no change link, because a wrong merge is permanent. CoinJoin-shaped transactions are detected first and skipped, so unrelated participants are never merged. Wallets are merged with union-find (DSU), and cluster_collapse_guard flags any cluster above graph.collapse_guard.max_cluster_wallets (default 500) as suspicious_merge — needs review rather than trusting it.

graph/entity_graph.py collapses that into one node per cluster for the dashboard's link-analysis view: aggregated value, transaction counts, txids per link, broadcast IPs and the collapse-guard flag per entity.

Features
python -m features.engineer --input data/processed/transactions.parquet \
    --output data/processed/features.parquet

Two grains, two tables. Per entity (features.parquet): fan_in_ratio / fan_out_ratio (how one-sided the entity is — 1.0 is a pure collector, 0.0 a pure distributor; raw counterparty and transaction counts are kept alongside so concentration is still derivable), round_amount_ratio, velocity (txs/day), lifetime_days, dormant_then_active with the gap and burst that triggered it, unique_broadcast_ips, unique_asns, suspicious_merge, and a zero-initialised avg_hop_distance_from_known_bad for fusion/taint.py to fill.

Per transaction (features_tx.parquet): equal_output_count (the CoinJoin signal), peel_ratio (only meaningful with exactly two outputs, NaN otherwise), input_count, output_count, time_since_prev_tx_same_wallet.

Thresholds — round units, dormancy gap and burst, velocity window — live under features: in config.yaml.

Rules engine
python -m engines.rules --input data/processed/transactions.parquet \
    --output data/processed/rule_alerts.parquet

Four detectors, each emitting Alert{entity_id, rule_name, score, reason, evidence} (engines/rules/schema.py). The reason is plain English with the numbers that made the rule fire — it is what the explainability layer will quote:

received from 32 distinct wallets, 32 of them first-time senders (100%),
then peeled funds through 8 hops
transaction has 6 outputs of equal value 0.10000000 BTC, consistent with a CoinJoin mix
funds moved through a 3-hop peeling chain, each hop retaining under 11% of the value
funds split across 4 wallets and re-merged into one transaction of 4 inputs within 2.0 hours

Scores start at base_score when a rule only just meets its threshold and climb to 1.0 as evidence accumulates; min_score decides what is worth writing. Every threshold is under engines.rules in config.yaml.

Published recall against the generator's ground truth lives in eval/results.md §1 (per typology, actor unit). pytest -rP tests/test_rules.py prints the same shape of number while tuning a threshold, but it is a working tool, not a source to quote: figures in this README and the slides come from python -m eval.report and nowhere else.

GNN engine
pip install -e '.[gnn]'      # torch is an extra; nothing else needs it
python -m engines.gnn.train --input data/processed/transactions.parquet \
    --ground-truth data/raw/ground_truth.json
python -m engines.gnn.infer --output data/processed/gnn_scores.parquet

A simplified GINe adapted from Multi-GNN: nodes are entities, edges are transactions, and the label sits on the edge. Kept from their design — edge-conditioned messages, the (x + relu(norm(conv)))/2 residual, optional edge updates between layers, chronological splits. Dropped — the GAT/PNA/RGCN variants, port numbering, reverse message passing. Two UTXO adaptations their bank-ledger model does not need: a transaction expands to every (input entity → output entity) pair, and a pure-change transaction keeps a self-loop so it is still scored.

⚠️ The labels are synthetic. This model is trained on patterns we invented in generator/, so its accuracy figures describe our simulator, not Bitcoin. NTRO's real data arrives unlabelled, meaning this model can be applied there but never retrained, and its error rate there is unknown. It is one signal among several in fusion/; the rules engine is what an investigator should see first. The full caveat is in engines/gnn/train.py's docstring.

IP correlation
python -m engines.correlation.scorer --input data/processed/transactions.parquet \
    --output data/processed/ip_correlation.parquet

Our original contribution: joining network-layer observations to blockchain clusters — the gap none of the reference implementations in vendor/ fill.

⚠️ Every score is a probabilistic lead, never an attribution. The output answers "how confident are we that IP X is associated with cluster Y", and may never be restated as "IP X belongs to Y". The person broadcasting a transaction is not necessarily the wallet's owner; our records are gossip observations where a forwarding node looks like an originating one; IPs are shared and reassigned. A high score is a reason to seek a warrant or corroboration — it is not evidence of who did anything. The full reasoning is in engines/correlation/scorer.py's docstring, and test_module_never_claims_ownership guards the wording.

final_score = raw_confidence × asn_penalty × shared_ip_penalty

raw_confidence    1 - exp(-observations / k) — saturating, never reaches 1.0
asn_penalty       ×0.2 for VPN / hosting / Tor-adjacent ASNs
shared_ip_penalty decays as one IP touches more unrelated clusters

Observations count distinct transactions, not relay rows. CoinJoins are excluded — one participant broadcasts for everybody. Measured: links scoring ≥ 0.4 name the true broadcast IP 100% of the time; links below 0.2 name it 38.6% (standard set) / 42.6% (shifted) of the time; every link resting on 3 or more distinct transactions named it correctly. Of the leads an analyst is actually shown beside an alert, 52.6% (standard) / 61.3% (shifted) name the actor's true broadcast address — against a ceiling of 84.2%, since a transaction broadcast through Tor has no observable true origin at all. (eval/results.md §6.)

Origin estimation
bash offline/fetch_intel.sh            # once, while online
python -m engines.propagation.pipeline # writes data/processed/tx_origins.parquet

ingest/ip_intel.py classifies an IP as known_bitcoin_relay, tor_exit, hosting_vpn or residential_or_unknown from locally cached public data — a Bitnodes snapshot, the Tor Project's exit list and a curated hosting-ASN list, each recorded in data/intel/manifest.json with its source URL and SHA-256.

engines/propagation/ rebuilds each transaction's propagation tree from the src→dst relay records and estimates which IP originated it. Three estimators, kept comparable: first_timestamp (first-spy baseline), rumor_centrality (Shah & Zaman, IEEE Trans. Inf. Theory 2011) and timestamp_weighted_centrality (Fanti & Viswanath, arXiv:1703.08761). Public relays are heavily down-weighted — a node that forwards everyone's traffic is the least informative candidate.

Measured (eval/results.md §2): the binding constraint is the ceiling — how often the true origin appears in the observed tree at all. It has two denominators, and they are not interchangeable:

relay-observation rate across every transaction across the multi-hop transactions an estimator is asked about
0.1 60.8% 75.1%
0.3 80.2% 83.7%
0.6 94.1% 94.2%

The second is the one that bounds accuracy; the first describes the evidence an operator actually holds. At rate 0.3, each estimator's top-1 as a share of that ceiling is 0.893 for first_timestamp, 0.824 for the hybrid and 0.775 for rumor centrality. That is much closer than the figures previously quoted here implied — the gap between the three is real but modest, and most of what separates raw accuracy is the ceiling, not the estimator. Rumor centrality still comes last because our origin is a degree-1 leaf while the estimator seeks the tree's centre, a prior that suits complete infected subtrees rather than sparse samples. Default is first_timestamp on that evidence.

If a dataset has no multi-hop records at all, origin estimation reports degraded mode and /stats says so, rather than implying an estimate was made.

Fusion
python -m fusion.pipeline    # every engine, then one ranked explained alert list

fusion/taint.py propagates suspicion outward from an analyst watchlist — and only from there — through the entity graph, halving per hop and stopping after 4, recording the taint_path so an investigator can dismiss an inherited score. fusion/stacker.py combines four signals — rule, anomaly, GNN, taint — with logistic regression under a non-negative constraint, chosen because its coefficients are themselves part of the deliverable. Correlation is not in the score: it says something about who, not about how risky an entity is, and is surfaced per alert as attribution leads. fusion/explain.py produces exact SHAP values (a logistic model is additive, so there is nothing to approximate) and a plain-English reason built from this entity's real numbers.

⚠️ The headline AUC used to be circular and no longer is. Taint was seeded from our own rules engine, which fires on the same clusters the labels describe, so taint_score alone scored ~0.999 AUC. That figure described the pre-fix circular setup and is kept here only to say what was wrong; it is not a current number. Seeds now come from the analyst watchlist only: taint alone scores 0.681 and the stacker 0.668 on the actor label (eval/results.md §1). Every fitted model still carries an ablation block with each signal alone and the stack without it; read it before quoting a number. Anomaly comes out below chance on our data (0.175 standard / 0.178 shifted, eval/results.md §1 and §8) because exchanges are the biggest outliers and our illicit actors use fresh wallets — the non-negative constraint clamps its weight to zero rather than learning a negative one that would not transfer.

The unit of detection is the actor, not the wallet: one ground-truth illicit operation, pre-registered in docs/detection_unit_protocol.md. Case detection rate, alert precision and trace coverage are the numbers that matter; wallet recall is reported as secondary.

API
uvicorn api.app:app --host 127.0.0.1 --port 8000

GET /stats — dashboard header: entity and alert counts, alerts by pattern type, mean confidence, plus pipeline status including the degraded-mode flag. GET /alerts?limit=50&offset=0&min_score=&entity_type=&pattern_type= — the ranked, explained alert list, highest risk first. GET /entities/{entity_id} — features, every engine score, the reason, the evidence, the taint path and the attribution leads. GET /entities/{entity_id}/graph?hops=2 — the surrounding wallets, transactions and IPs as Cytoscape elements. GET /entities/{entity_id}/report — a one-page PDF case report (ReportLab). POST /alerts/{alert_id}/feedback {"status": "confirmed" | "false_positive"} — appended to data/processed/feedback.parquet for recalibrating the stacker. The alert id is the entity id: there is one alert per entity. GET /transactions/{txid}/propagation — the propagation tree as Cytoscape elements, with layout: {name: dagre, roots: [origin]}, the estimated origin marked origin, runner-ups runner_up, and an IP-class badge on every node. GET /transactions/{txid}/origination — the origination model's answer per capture the txid was seen in. GET /peers/{peer}/profile, GET /asns/{asn}/profile — the reverse direction: what a peer (IP or onion identity) or every peer of an ASN did on the network. Each lookup is written to the custody ledger. See docs/CORRELATION.md. GET /tags/{entity|transaction|actor|peer}/{subject} — attribution tags from the offline tag store (intel/): sealed bundles (OFAC SDN, operator CSV lists, a simulated demo bundle) carried across the air gap and imported with python -m intel.bundle import. Each tag shows its source and date, and conflicts are shown, never resolved. Tags feed fusion as tag_score. See docs/TAGSTORE.md. GET /exit-points/{address|entity|actor}/{subject} (and /packet for the PDF) — trace a seed's funds forward under haircut and poison taint, stop at CoinJoins, and rank clusters tagged exchange/VASP as candidate cash-out points. It is an investigative lead, not proof of ownership. See docs/EXIT_POINTS.md.

No authentication, deliberately, for the demo — it binds to localhost and serves synthetic data. A deployment would need auth, per-case authorisation, an audit log of who read which entity, and a CORS list that is not a dev-server convenience. Said again at the top of api/app.py.

Live monitoring

The rest of this system is a batch pipeline: point it at a dump, get a ranked alert list. That is the analysis half of the problem. The monitoring half is that metadata keeps arriving, and an analyst wants to know within seconds whether any of it matters.

POST /monitor/start        # begin watching data/inbox
GET  /monitor/events       # server-sent events: every arrival, every alert it raised
GET  /monitor/status       # files, transactions, alerts, duplicates, errors
POST /monitor/simulate     # generate one batch of fresh traffic, for a demo
POST /monitor/stop

Anything copied into data/inbox — a collection run, an upstream export, another agency's batch — is parsed, validated, folded into the graph the API already holds, scored, and pushed to the console at /monitor. It reuses the incremental path (fusion/incremental.py), so an arrival costs the same few seconds a red-team injection does and nothing is retrained.

Three behaviours worth knowing:

  • Arrivals are idempotent. A file dropped twice, or overlapping with one already seen, contributes only its unseen transactions — folding a transaction in twice would double every amount on it.
  • A file is read only once it stops growing, so a writer still copying into the directory is never parsed half-written.
  • A rejected file says so. A file whose every row fails validation is reported as quarantined with a reason, not drawn the same way as a quiet one — a feed whose upstream has changed format must not look like a feed with nothing to report.

Handled files are moved to data/processed/monitor_archive/, never deleted: the custody ledger points at them.

python -m api.monitor feed --batches 6 --interval 4   # synthetic arrivals
Chain of custody

Every consequential action is appended to a hash-chained, append-only ledger: data acquired, pipeline run, traffic arrived, analyst verdict, case report exported, red-team injection, dataset restored. Source files are hashed at acquisition; an exported PDF carries the ledger head and the hashes of the data it was made from, and the ledger carries the PDF's own hash.

python -m custody log       # what happened
python -m custody verify    # is the chain intact, are the files unchanged

Each entry's hash is computed over its content and its predecessor's hash, so an edited or deleted entry cannot be made to verify — the record is tamper-evident. It does not prove who did the work: this build has no authentication and says so rather than inventing an actor a court would have to test. Full detail, including the four ways tests/test_custody.py breaks the ledger on purpose, is in docs/chain_of_custody.md. The console draws it at /custody.

Red team

A demo surface for the one question an audience actually wants answered: can I make it miss? Open /redteam in the console, pick a laundering typology, set its parameters, and inject it into the dataset the detectors have already run over. The stack folds the new transactions into the state it already holds — fusion/incremental.py — and reports what it found.

POST /redteam/runs          # start a run, returns a run_id immediately
GET  /redteam/runs/{id}/events   # server-sent events: stage name + elapsed
GET  /redteam/runs/{id}     # the finished result
GET  /redteam/runs          # the scoreboard, with a detection rate per typology
GET  /redteam/typologies    # what can be injected, and which controls each reads
POST /redteam/snapshot      # take the reset point before the demo starts
POST /redteam/reset         # restore the dataset and forget the runs

Three commitments, because a red-team demo that cannot lose proves nothing:

  • Nothing is retrained. The GNN and the stacker are loaded and scored by inference. A detector refitted around the attack it is being asked to find has been told the answer, not tested.
  • A miss is reported as fully as a catch. When nothing alerts, the run returns every engine's score for the injected entities against the threshold they did not clear, and the page shows how far short each one fell.
  • Origin estimation is scored honestly. The result gives the rank of the true broadcast IP among the estimator's candidates. A null rank means the true origin never appeared in the observed hops — broadcast over Tor, for instance — which no estimator can fix, and which the page says outright.

POST /redteam/reset restores the raw dataset and everything derived from it (the parquet and the alert list), so the console goes back to the case it was showing before anyone touched it. tests/test_redteam.py checks the restore by file hash, and checks that an incremental re-run gives the same scores as a full one — which is the only thing that makes "incremental" a speed-up rather than a different, faster, wrong detector.

How fast a run is depends on the machine's state, so this README quotes no wall-clock times. The machine-independent measure is transactions-to-detect, and docs/CLAIMS.md says which performance numbers may be quoted and how to word them. The profiling (the stage that dominates, and the optimisation that turned out to be slower) is in docs/redteam_performance.md.

Running it offline

The whole system installs and runs on a machine that has never been online. Two halves: one script gathers everything while you still have internet, another installs it where you do not.

On a machine with internet, from a clone of this repository:

offline/build_wheelhouse.sh                        # for this machine's Python
offline/build_wheelhouse.sh --python-version 3.12  # for the demo machine's
offline/build_wheelhouse.sh --no-gnn               # skip torch, ~250 MB smaller

That downloads every wheel — including the PyTorch CPU build, from PyTorch's own index — into offline/wheelhouse/, runs offline/fetch_intel.sh and copies the IP intelligence snapshot and its manifest into the bundle, builds the console into web/dist/, stages the trained weights into offline/models/, and writes offline/bundle.json recording what was built and the hash of every file in it.

Wheels are tagged with the interpreter version: a wheelhouse built for 3.14 will not install on 3.12. Check the target machine's python3 -V first.

On the air-gapped machine, with the repository copied across:

./offline/install.sh          # venv + pip install --no-index --find-links offline/wheelhouse/
./offline/run_offline.sh      # console and API, one process, http://127.0.0.1:8000/app/

install.sh refuses to continue if this machine's Python does not match the one the wheels were built for — pip's own error for that names neither cause nor fix. It copies the model weights into models/ and the intel snapshot into data/intel/, then checks for the GeoIP databases and explains what degrades without them (see offline/GEOIP_SETUP.md — MaxMind requires a free account, so that one step cannot be automated).

FastAPI serves the built console itself, from web/dist, so the target machine needs no npm, no node_modules and no second web server. The console is mounted at /app/ rather than at the root because three of its routes — /alerts, /entities/{id}, /custody — are also API paths; / redirects.

Proving it

python -m pytest tests/test_offline_guarantee.py -q
unshare -rn sh -c 'ip link set lo up; ./offline/airgap_check.sh'

The first greps every file we wrote for requests.get, urlopen, httpx, sockets, curl/wget pointed anywhere but this machine, and any fetch(), EventSource or WebSocket given a URL that is not same-origin — in the source and in the built bundle. Two scripts are allowed to use the network, both of which run on the online machine, and each is named in the test with its reason.

The second runs the whole checklist inside a kernel network namespace that has nothing but loopback — a real air gap, no root and without taking your own machine off the network: the pipeline, the API, the console's routes and assets, the graph, the PDF (checking the custody ledger's hash matches the file that was downloaded), the live monitor, and the test suite. 26 checks, PASS or FAIL each. offline/OFFLINE_CHECKLIST.md has the manual items too — a script cannot tell you whether the graph looks right.

Evaluation
python -m eval.report      # writes eval/results.md

One command, one results file, one canonical setup (seed and sizes in config.yaml under eval:). Every number quoted anywhere in this project comes from here — an earlier version quoted two different "ceiling" figures for the same statistic because two ad-hoc runs used different dataset sizes.

docs/origin_eval_protocol.md pre-registers how the default origin estimator is chosen, and was committed before the topology fix so the rule could not be written around the result.

The shifted set (--shifted) deforms the typologies — jittered peel ratios, deeper chains, longer windows, interleaved CoinJoins. Where both are reported, the shifted number is the headline.

Configuration

Everything tunable lives in config.yaml at the repo root: input schema field names, GeoIP database paths, model paths, risk-score weights, thresholds. No stage hardcodes these — NTRO's real field names will differ from our synthetic ones, so remap in config.yaml and change nothing else.

import config

config.field("tx_id")            # -> the column name in the incoming dataset
config.get("risk_weights.rules") # -> 0.35

Team JL · Smart India Hackathon 2026 · Problem SIH26146

MIT, see LICENSE. vendor/ is excluded; each vendored repo keeps its own license.

About

Offline, air-gapped Bitcoin transaction-traffic intelligence for SIH 2026 (PS 26146, NTRO). Fuses P2P relay captures with on-chain data to infer transaction origin with calibrated confidence, abstains where attribution is provably impossible, and produces tamper-evident investigator packets.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages