btc-intel / graph /entity_graph.py
Anish-g's picture
Deploy btc-intel on free ZeroGPU Gradio Space
824aa03 verified
Raw History Blame Contribute Delete
4.21 kB
"""Collapse the wallet graph into one node per entity (cluster).
This is the link-analysis view the dashboard draws: a few thousand entities
instead of a few hundred thousand addresses. Flags from the collapse guard ride
along, so an oversized, untrusted cluster is visibly untrusted on screen.
"""
from __future__ import annotations
from collections.abc import Iterable
import networkx as nx
import config
from .builder import Tx, graph_transactions
from .clustering import Clustering
ENTITY = "entity"
def _as_transactions(source) -> list[Tx]:
if isinstance(source, nx.MultiDiGraph):
return list(graph_transactions(source))
return list(source)
def build_entity_graph(source, clustering: Clustering, cfg: dict | None = None) -> nx.DiGraph:
"""Entity -> entity value flow. Self-loops (change, internal moves) are
counted on the node rather than drawn as edges."""
cfg = cfg or config.load()
txs = _as_transactions(source)
g = nx.DiGraph()
def entity(addr: str) -> str:
return clustering.cluster_of(addr) or addr
for cid, members in clustering.clusters.items():
g.add_node(cid, node_type=ENTITY, wallets=len(members),
flag=clustering.flags.get(cid), ips=set(), txs=0, internal_txs=0,
value_in=0.0, value_out=0.0)
for tx in txs:
mix = int(tx.txid in clustering.coinjoins)
senders: dict[str, float] = {}
for addr, value in tx.inputs:
senders[entity(addr)] = senders.get(entity(addr), 0.0) + value
receivers: dict[str, float] = {}
for addr, value in tx.outputs:
receivers[entity(addr)] = receivers.get(entity(addr), 0.0) + value
total_in = sum(senders.values()) or 1.0
for eid in set(senders) | set(receivers):
if eid not in g:
g.add_node(eid, node_type=ENTITY, wallets=1, flag=None, ips=set(),
txs=0, internal_txs=0, value_in=0.0, value_out=0.0)
g.nodes[eid]["txs"] += 1
g.nodes[eid]["ips"].update(tx.ips)
for eid, value in senders.items():
g.nodes[eid]["value_out"] += value
for eid, value in receivers.items():
g.nodes[eid]["value_in"] += value
for src, sent in senders.items():
share = sent / total_in
for dst, received in receivers.items():
if src == dst:
g.nodes[src]["internal_txs"] += 1
continue
value = received * share
if g.has_edge(src, dst):
e = g.edges[src, dst]
e["value"] += value
e["count"] += 1
e["mix_count"] += mix
e["txids"].append(tx.txid)
e["last_seen"] = max(e["last_seen"], tx.timestamp) if tx.timestamp else e["last_seen"]
else:
# ponytail: full txid list per edge — fine at case scale; swap
# for a count plus a sample if an edge ever holds millions.
# mix_count: how many of those transactions were CoinJoins
# (clustering.is_coinjoin). fusion.taint does not cross an
# edge made of nothing else.
g.add_edge(src, dst, value=value, count=1, mix_count=mix, txids=[tx.txid],
first_seen=tx.timestamp, last_seen=tx.timestamp)
for _, d in g.nodes(data=True):
d["ips"] = sorted(d["ips"])
d["shared_ip_count"] = len(d["ips"])
return g
def top_entities(g: nx.DiGraph, by: str = "value_out", limit: int = 10) -> list[tuple[str, float]]:
return sorted(((n, d.get(by, 0)) for n, d in g.nodes(data=True)),
key=lambda x: -x[1])[:limit]
def summary(g: nx.DiGraph) -> dict:
flagged = [n for n, d in g.nodes(data=True) if d.get("flag")]
return {"entities": g.number_of_nodes(), "links": g.number_of_edges(),
"flagged": len(flagged),
"value": round(sum(d["value"] for *_, d in g.edges(data=True)), 8),
"largest_entity": max((d["wallets"] for _, d in g.nodes(data=True)), default=0)}