/** The entity's neighbourhood, and — one tap away — how a transaction in it * spread across the network. * * Two views, one surface. The subgraph answers "who is this connected to"; * the propagation tree answers "where did this broadcast come from", which is * a different question with a different confidence attached to it, so the * panel says which one you are looking at and never mixes them. */ import { useCallback, useMemo, useState } from "react"; import type { ElementDefinition, LayoutOptions } from "cytoscape"; import { api } from "../api/client"; import type { EntityGraph, Propagation } from "../api/types"; import { ipClass } from "../lib/format"; import { formatId } from "../lib/formatId"; import { token } from "../lib/tokens"; import { FieldGraph } from "./FieldGraph"; import { Chip, validityLabel } from "./ui"; const nodeColour = (type: string) => token( type === "wallet" ? "--node-wallet" : type === "ip" ? "--node-ip" : "--node-transaction", ); const roleColour = (role: string) => token( role === "origin" ? "--node-taint" : role === "runner_up" ? "--node-ip" : "--node-transaction", ); /** Node size carries risk: the entity under investigation and its own wallets * are drawn larger than the surroundings they touch. */ function toElements(graph: EntityGraph, riskScore: number): ElementDefinition[] { const base = 12 + Math.round(riskScore * 8); return [ ...graph.elements.nodes.map(({ data }) => ({ data: { ...data, colour: nodeColour(String(data.type)), size: data.is_focus ? base : data.type === "transaction" ? 6 : 9, focus: data.is_focus ? 1 : 0, label: data.type === "ip" ? String(data.label) : formatId(String(data.label ?? data.id)), }, })), ...graph.elements.edges.map(({ data }) => ({ data })), ]; } function propagationElements(tree: Propagation): ElementDefinition[] { return [ ...tree.elements.nodes.map(({ data }) => ({ data: { ...data, colour: roleColour(String(data.role)), size: data.role === "origin" ? 14 : data.role === "runner_up" ? 10 : 7, focus: data.role === "origin" ? 1 : 0, }, })), ...tree.elements.edges.map(({ data }) => ({ data })), ]; } export function GraphPanel({ graph, taintPath, riskScore, }: { graph: EntityGraph; taintPath: string[]; riskScore: number; }) { const [tree, setTree] = useState(null); const [selected, setSelected] = useState<{ id: string; type?: string } | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const elements = useMemo( () => (tree ? propagationElements(tree) : toElements(graph, riskScore)), [tree, graph, riskScore], ); const layout = useMemo( () => tree ? (// Wide separation on purpose: these labels are full IP // addresses and they must not overlap. { name: "dagre", rankDir: "TB", nodeSep: 86, rankSep: 64 } as unknown as LayoutOptions) : ({ name: "cose", idealEdgeLength: 70, nodeRepulsion: 9000, padding: 24 } as unknown as LayoutOptions), [tree], ); const onNodeTap = useCallback((node: { id: string; type?: string }) => { setSelected(node); }, []); const showPropagation = async (txid: string) => { setLoading(true); setError(null); try { setTree(await api.propagation(txid)); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } }; return (
{tree ? "propagation tree" : `neighbourhood · ${graph.hops} hops`} {tree ? ( <> {formatId(tree.txid)} ) : selected?.type === "transaction" ? ( ) : ( select a transaction node to trace its broadcast )} {graph.truncated && !tree && capped}
{tree ? (
estimated origin runner-up relay hop {ipClass(tree.ip_class).label} · confidence {tree.confidence.toFixed(2)} {tree.low_confidence_origin && " · ⚠ low confidence origin"} {tree.anonymized_entry_point && " · ⚠ anonymized entry point"} {tree.validity.tier === "PASS" ? " · validity pass" : ` · ⚠ ${validityLabel(tree.validity)}`}
) : (
wallet ·{" "} {graph.counts.wallets} transaction ·{" "} {graph.counts.transactions} ip · {graph.counts.ips} {taintPath.length > 1 && ( taint path )}
)} {error && (

Could not load the propagation tree: {error}

)} {tree?.caveat &&

{tree.caveat}

}
); }