Download analysis/validity.py from Anish-g/btc-intel: direct link, hf CLI and curl.
- Browser
- Download file 12.8 kB
-
https://huggingface.co/spaces/Anish-g/btc-intel/resolve/main/analysis/validity.py
- Command line
-
hf download hf://spaces/Anish-g/btc-intel/analysis/validity.py
-
curl -L -o validity.py https://huggingface.co/spaces/Anish-g/btc-intel/resolve/main/analysis/validity.py
12.8 kB
| """The validity layer: when an origin attribution is invalid, or means less than | |
| it appears to, say so — and say which. | |
| Calibration answers "how often is a 0.8 right?". This module answers prior | |
| questions: can an attribution be about this transaction at all, and if so, | |
| what may it claim? docs/VALIDITY.md is the taxonomy and the pre-registered | |
| metric revision. Every reason that fires is reported; the verdict's tier is | |
| the most severe among them: | |
| ABSTAIN DEGENERATE candidate_count 0 or 1: nothing to rank. | |
| NOT_REACHABLE every candidate is a known public relay (the | |
| matrix's `scope_out`): the zero-ceiling case. | |
| QUALIFIED COINJOIN the answer is the broadcasting peer only; the | |
| inputs' ownership is not attributable. | |
| TOR_ONION the answer is an onion identity, never an IP. | |
| ANNOTATE DANDELION_STEM the first announcement stands alone before a | |
| burst, the shape a stem leaves. Flag only: | |
| Dandelion is not deployed in Bitcoin Core. | |
| V2_PASSIVE_TAP a pcap capture held port-8333 flows it could not | |
| decode (BIP-324 v2): v2 peers may be missing from | |
| the candidates. Never on debug.log/.btcap, which | |
| the node — a session endpoint — writes. | |
| Only ABSTAIN withholds an answer, and it does so through the existing rule — | |
| `low_confidence_origin` (engines.propagation) and `eval.origin.flagged_at` — | |
| not a second path. `validity.enforce: false` switches the layer off there; | |
| `validity.mode: binary` restores P6's gate, where every non-PASS verdict | |
| abstained, so that policy can still be scored. | |
| """ | |
| from __future__ import annotations | |
| import statistics | |
| from dataclasses import dataclass | |
| import pandas as pd | |
| import config | |
| from graph.builder import Tx | |
| from graph.clustering import equal_value_group, is_coinjoin | |
| PASS = "PASS" | |
| DEGENERATE = "DEGENERATE" | |
| NOT_REACHABLE = "NOT_REACHABLE" | |
| COINJOIN = "COINJOIN" | |
| TOR_ONION = "TOR_ONION" | |
| DANDELION_STEM = "DANDELION_STEM" | |
| V2_PASSIVE_TAP = "V2_PASSIVE_TAP" | |
| #: Most severe first; `assess` reports them in this order. | |
| REASONS = (DEGENERATE, NOT_REACHABLE, COINJOIN, TOR_ONION, DANDELION_STEM, V2_PASSIVE_TAP) | |
| ABSTAIN, QUALIFIED, ANNOTATE = "ABSTAIN", "QUALIFIED", "ANNOTATE" | |
| TIER = {DEGENERATE: ABSTAIN, NOT_REACHABLE: ABSTAIN, | |
| COINJOIN: QUALIFIED, TOR_ONION: QUALIFIED, | |
| DANDELION_STEM: ANNOTATE, V2_PASSIVE_TAP: ANNOTATE} | |
| #: The columns a frame of origins carries. | |
| COLUMNS = ["validity", "validity_tier", "validity_reasons", "validity_confidence", | |
| "validity_evidence"] | |
| class Verdict: | |
| reason: str = PASS # the most severe reason that fired | |
| confidence: float | None = None # that it is present; None on PASS | |
| evidence: tuple[str, ...] = () | |
| reasons: tuple[str, ...] = () # every reason that fired, in REASONS order | |
| def passed(self) -> bool: | |
| return self.reason == PASS | |
| def tier(self) -> str: | |
| return TIER.get(self.reason, PASS if self.passed else self.reason) | |
| def as_dict(self) -> dict: | |
| return {"tier": self.tier, "reason": None if self.passed else self.reason, | |
| "reasons": list(self.reasons), "confidence": self.confidence, | |
| "evidence": list(self.evidence)} | |
| def columns(self) -> dict: | |
| return {"validity": self.reason, "validity_tier": self.tier, | |
| "validity_reasons": list(self.reasons), | |
| "validity_confidence": self.confidence, | |
| "validity_evidence": list(self.evidence)} | |
| VALID = Verdict(PASS, None, ("no invalidating condition detected",)) | |
| #: Not a detector: what an output stored before this layer existed carries, so | |
| #: that it is never served without a verdict and never passed off as PASS. | |
| NOT_ASSESSED = Verdict("NOT_ASSESSED", None, ( | |
| "produced before the validity layer existed; rerun the pipeline to assess it",), | |
| ("NOT_ASSESSED",)) | |
| def enforced(cfg: dict) -> bool: | |
| return bool(cfg.get("validity", {}).get("enforce", True)) | |
| def tiered(cfg: dict) -> bool: | |
| """Qualifications are in force: the layer is on and not in P6's binary mode.""" | |
| return enforced(cfg) and cfg.get("validity", {}).get("mode", "tiered") == "tiered" | |
| def withholds(tier: str, cfg: dict) -> bool: | |
| """Whether a verdict of this tier abstains under the configured policy.""" | |
| if not enforced(cfg): | |
| return False | |
| return tier == ABSTAIN if tiered(cfg) else tier != PASS | |
| def answer(peer: str | None, verdict: Verdict, cfg: dict) -> dict | None: | |
| """What an origin answer may claim. A QUALIFIED answer is never shaped like | |
| an IP attribution: an onion identity has no `ip`, and a CoinJoin | |
| broadcaster carries an explicit `input_ownership: not attributable`.""" | |
| if peer is None or withholds(verdict.tier, cfg): | |
| return None | |
| qualified = tiered(cfg) | |
| if qualified and TOR_ONION in verdict.reasons: | |
| out = {"kind": "onion_identity", "onion": peer, | |
| "actionable": "not for IP-level follow-up"} | |
| elif qualified and COINJOIN in verdict.reasons: | |
| out = {"kind": "broadcasting_peer", "ip": peer} | |
| else: | |
| return {"kind": "ip_attribution", "ip": peer} | |
| if COINJOIN in verdict.reasons: | |
| out["input_ownership"] = "not attributable: a CoinJoin's inputs have many owners" | |
| return out | |
| # --- the detectors ---------------------------------------------------------- | |
| def degenerate(candidate_count: int) -> Verdict | None: | |
| if candidate_count > 1: | |
| return None | |
| return Verdict(DEGENERATE, 1.0, ( | |
| f"{candidate_count} peer{'s' if candidate_count != 1 else ''} announced this " | |
| "transaction to the observer, so there is nothing to compare",)) | |
| def not_reachable(scope_out: bool) -> Verdict | None: | |
| if not scope_out: | |
| return None | |
| return Verdict(NOT_REACHABLE, 1.0, ( | |
| "every peer that announced it is a known public relay; the sender is not " | |
| "among the candidates this observer can see",)) | |
| def coinjoin(tx: Tx | None, cfg: dict) -> Verdict | None: | |
| """`graph.clustering.is_coinjoin` — the check clustering already uses to skip | |
| common-input-ownership, so a mix is one thing everywhere.""" | |
| if tx is None or not is_coinjoin(tx, cfg): | |
| return None | |
| value, group = equal_value_group(tx.output_values, | |
| cfg["graph"]["coinjoin"]["equal_value_tolerance"]) | |
| target = cfg["engines"]["rules"]["coinjoin"]["target_equal_outputs"] | |
| return Verdict(COINJOIN, round(min(1.0, len(group) / target), 3), ( | |
| f"{len(group)} of {len(tx.outputs)} outputs pay {value:.8f} BTC, from " | |
| f"{len(tx.inputs)} inputs", | |
| "a CoinJoin has one broadcaster and many owners: the answer names who " | |
| "broadcast it, never who owns its inputs")) | |
| def tor_onion(peer: str | None) -> Verdict | None: | |
| if not (peer and str(peer).endswith(".onion")): | |
| return None | |
| return Verdict(TOR_ONION, 1.0, ( | |
| f"{peer} is a Tor hidden service: the answer is that onion identity, which " | |
| "names no IP and supports no IP-level follow-up",)) | |
| def v2_passive_tap(capture_source: str | None, unreadable_flows) -> Verdict | None: | |
| """Only a packet capture is blind to BIP-324: bitcoind's own log, and a | |
| .btcap its collector writes, come from the node, which decrypts.""" | |
| if not (capture_source and str(capture_source).startswith("pcap:")): | |
| return None | |
| if not unreadable_flows or pd.isna(unreadable_flows) or int(unreadable_flows) <= 0: | |
| return None | |
| n = int(unreadable_flows) | |
| return Verdict(V2_PASSIVE_TAP, 1.0, ( | |
| f"this packet capture held {n} port-8333 flow{'s' if n != 1 else ''} it could not " | |
| "decode, consistent with BIP-324 v2 encryption: peers on those links are missing " | |
| "from the candidates",)) | |
| def dandelion_stem(times, cfg: dict) -> Verdict | None: | |
| """First-announcement isolation. A stem hands the transaction to one peer | |
| at a time; an observer on or beside the stem hears one announcement, then | |
| nothing, then the broadcast arrives as a burst. Ordinary diffusion reaches | |
| an observer's neighbours at a steadier pace. | |
| The statistic is the gap after the first announcement over the median gap | |
| among the rest. Its threshold is fixed in config.yaml, before any result. | |
| """ | |
| d = cfg["validity"]["dandelion"] | |
| t = sorted(float(x) for x in times) | |
| if len(t) < d["min_candidates"]: | |
| return None | |
| gaps = [b - a for a, b in zip(t, t[1:])] | |
| later = statistics.median(gaps[1:]) | |
| if gaps[0] <= 0: | |
| return None | |
| ratio = gaps[0] / later if later > 0 else float("inf") | |
| if ratio < d["isolation_ratio"]: | |
| return None | |
| return Verdict(DANDELION_STEM, round(1.0 - 0.5 * d["isolation_ratio"] / ratio, 3), ( | |
| f"the first announcement stood alone for {gaps[0] * 1000:.0f} ms, " | |
| f"{ratio:.1f}x the median gap between the {len(t) - 1} that followed", | |
| "consistent with a Dandelion stem, where the first announcer may be a relay; " | |
| "a flag only — Bitcoin Core has no stem phase")) | |
| # --- one origin -------------------------------------------------------------- | |
| def combine(fired: list[Verdict]) -> Verdict: | |
| """Every verdict that fired -> one: the most severe leads, all are listed.""" | |
| fired = sorted(fired, key=lambda v: REASONS.index(v.reason)) | |
| if not fired: | |
| return VALID | |
| return Verdict(fired[0].reason, fired[0].confidence, | |
| tuple(line for v in fired for line in v.evidence), | |
| tuple(v.reason for v in fired)) | |
| def assess(candidate_count: int, scope_out: bool = False, peer: str | None = None, | |
| times=(), tx: Tx | None = None, cfg: dict | None = None, | |
| capture_source: str | None = None, unreadable_flows=None) -> Verdict: | |
| cfg = cfg or config.load() | |
| fired = [v for v in (degenerate(candidate_count), not_reachable(scope_out), | |
| coinjoin(tx, cfg), tor_onion(peer), | |
| dandelion_stem(times, cfg), | |
| v2_passive_tap(capture_source, unreadable_flows)) | |
| if v is not None] | |
| return combine(fired) | |
| def assess_tree(tree, peer: str | None, intel, cfg: dict | None = None, | |
| tx: Tx | None = None, exclude=()) -> Verdict: | |
| """For an `engines.propagation` tree. Candidates and announcement times are | |
| the tree's senders (`first_sent`), less `exclude` (an observer).""" | |
| cfg = cfg or config.load() | |
| exclude = set(exclude) | |
| announced = tree.first_sent or tree.first_seen | |
| candidates = [ip for ip in tree.ips if ip not in exclude] | |
| count = 1 if tree.is_single_observation else len(candidates) | |
| relay = set(cfg["engines"]["propagation"]["low_confidence_classes"]) | |
| scope_out = bool(candidates) and all( | |
| intel.classify(ip, tree.graph.nodes.get(ip, {}).get("asn")).ip_class in relay | |
| for ip in candidates) | |
| return assess(count, scope_out, peer, | |
| [ts for ip, ts in announced.items() if ip not in exclude], tx, cfg) | |
| def tx_of(txid: str, shape) -> Tx | None: | |
| """A transaction's structure from a corpus row (`generator.typologies.shape`).""" | |
| if shape is None: | |
| return None | |
| return Tx(txid, list(zip(shape["in_addrs"], shape["in_vals"])), | |
| list(zip(shape["out_addrs"], shape["out_vals"]))) | |
| def assess_matrix(matrix: pd.DataFrame, named: pd.DataFrame, | |
| shapes: pd.DataFrame | None = None, cfg: dict | None = None) -> pd.DataFrame: | |
| """For the `features.relay` matrix: one verdict per (capture_id, txid), about | |
| the peer `named` says was chosen (`estimated_origin_ip`).""" | |
| cfg = cfg or config.load() | |
| key = ["capture_id", "txid"] | |
| chosen = named.set_index(key)["estimated_origin_ip"] | |
| shape_of = shapes.set_index(key) if shapes is not None and len(shapes) else None | |
| rows = [] | |
| for (capture_id, txid), group in matrix.groupby(key, sort=False): | |
| peer = chosen.get((capture_id, txid)) | |
| shape = (shape_of.loc[(capture_id, txid)] | |
| if shape_of is not None and (capture_id, txid) in shape_of.index else None) | |
| first = group.iloc[0] | |
| verdict = assess(int(first["candidate_count"]), bool(first["scope_out"]), peer, | |
| group["delta_vs_first_s"].to_numpy(), tx_of(txid, shape), cfg, | |
| first.get("capture_source"), first.get("unreadable_flows")) | |
| rows.append({"capture_id": capture_id, "txid": txid, **verdict.columns()}) | |
| return pd.DataFrame(rows, columns=key + COLUMNS) | |