Spaces:
Sleeping
Sleeping
import requests | |
import gradio as gr | |
import networkx as nx | |
import plotly.graph_objects as go | |
from datetime import datetime | |
import re | |
import os | |
import tempfile | |
import base64 | |
# ---------------- Funciones de análisis y grafo ------------------- | |
def get_transaction(tx_id): | |
""" | |
Obtiene los datos de una transacción Bitcoin usando endpoints públicos. | |
Se prueba primero con Blockstream.info y, de fallar, con mempool.space. | |
""" | |
urls = [ | |
f"https://blockstream.info/api/tx/{tx_id}", | |
f"https://mempool.space/api/tx/{tx_id}" | |
] | |
for url in urls: | |
try: | |
response = requests.get(url, timeout=10) | |
if response.status_code == 200: | |
return response.json() | |
else: | |
print(f"Error en {url}: Código de estado {response.status_code}") | |
except Exception as e: | |
print(f"Excepción al consultar {url}: {e}") | |
return None | |
def get_btc_price(): | |
""" | |
Obtiene el precio actual de Bitcoin en USD utilizando la API de CoinGecko. | |
""" | |
try: | |
response = requests.get("https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd", timeout=10) | |
if response.status_code == 200: | |
data = response.json() | |
price = data.get("bitcoin", {}).get("usd") | |
return price | |
except Exception as e: | |
print("Error fetching BTC price:", e) | |
return None | |
def generar_grafo(tx_data): | |
""" | |
Genera un grafo dirigido a partir de los datos de la transacción. | |
Nodo central: la transacción. | |
Nodos secundarios: direcciones de inputs y outputs. | |
""" | |
G = nx.DiGraph() | |
txid = tx_data.get("txid", "desconocido") | |
G.add_node(txid, color="#FF6B6B", size=20, tipo="Transacción") | |
for inp in tx_data.get("vin", []): | |
prevout = inp.get("prevout", {}) | |
addr = prevout.get("scriptpubkey_address") | |
if addr: | |
G.add_node(addr, color="#4ECDC4", size=15, tipo="Input") | |
G.add_edge(addr, txid) | |
for out in tx_data.get("vout", []): | |
addr = out.get("scriptpubkey_address") | |
if addr: | |
G.add_node(addr, color="#45B7D1", size=15, tipo="Output") | |
G.add_edge(txid, addr) | |
return G | |
def check_blockchain_tags(tx_id): | |
""" | |
Consulta la API pública de blockchain.com para ver si el TXID (o sus metadatos) | |
indica que la transacción ha sido etiquetada como MIXER. | |
(Esta función es un ejemplo y podría necesitar ajustes según la respuesta real.) | |
""" | |
url = f"https://blockchain.info/rawtx/{tx_id}?format=json" | |
try: | |
response = requests.get(url, timeout=10) | |
if response.status_code == 200: | |
data = response.json() | |
tags = data.get("tags", []) | |
if "MIXER" in tags: | |
return True | |
if data.get("category", "").upper() == "MIXER": | |
return True | |
else: | |
print(f"Error consultando blockchain.com: {response.status_code}") | |
except Exception as e: | |
print(f"Excepción en check_blockchain_tags: {e}") | |
return False | |
def analizar_transaccion(tx_id): | |
""" | |
Analiza una transacción Bitcoin: | |
- Obtiene datos desde Blockstream.info o mempool.space. | |
- Calcula totales en BTC y fee, mostrando también su equivalente en USD. | |
- Muestra información adicional (versión, tamaño, peso, fee rate). | |
- Aplica heurística para detectar posibles CoinJoin/mixers. | |
- Incorpora información adicional de blockchain.com (etiqueta MIXER). | |
- Genera un grafo interactivo. | |
- Retorna un informe en HTML, la figura del grafo y una tupla (reporte, fig) para su uso. | |
""" | |
tx_data = get_transaction(tx_id) | |
if not tx_data: | |
return ("❌ Transacción no encontrada o error al obtener datos. Asegúrate de ingresar un TXID válido."), None, None | |
try: | |
num_inputs = len(tx_data.get("vin", [])) | |
num_outputs = len(tx_data.get("vout", [])) | |
montos = [out.get("value", 0) / 1e8 for out in tx_data.get("vout", [])] | |
montos_unicos = len(set(montos)) | |
total_input_value = sum(inp.get("prevout", {}).get("value", 0) for inp in tx_data.get("vin", [])) / 1e8 | |
total_output_value = sum(out.get("value", 0) for out in tx_data.get("vout", [])) / 1e8 | |
fee = total_input_value - total_output_value | |
heuristic_mixer = ((num_inputs > 5 and num_outputs > 5 and montos_unicos < 3) or | |
(num_outputs > 2 and montos_unicos <= 2)) | |
es_mixer_blockchain = check_blockchain_tags(tx_id) | |
es_mixer = heuristic_mixer or es_mixer_blockchain | |
if es_mixer: | |
if heuristic_mixer and es_mixer_blockchain: | |
mixer_message = ( | |
"<p style='color: #FF5252;'><strong>Alerta de Mixer / CoinJoin:</strong> La transacción cumple criterios heurísticos y la API de blockchain.com la etiqueta como MIXER.</p>" | |
) | |
elif heuristic_mixer: | |
mixer_message = ( | |
"<p style='color: #FF5252;'><strong>Alerta de Mixer / CoinJoin:</strong> La transacción cumple criterios heurísticos compatibles con un mixer.</p>" | |
) | |
elif es_mixer_blockchain: | |
mixer_message = ( | |
"<p style='color: #FF5252;'><strong>Alerta de Mixer / CoinJoin:</strong> La API de blockchain.com etiqueta esta transacción como MIXER.</p>" | |
) | |
else: | |
mixer_message = ( | |
"<p style='color: #4CAF50;'><strong>Sin indicios de mezcla:</strong> La transacción no presenta patrones de mixer.</p>" | |
) | |
btc_price = get_btc_price() or 0 | |
version = tx_data.get("version", "N/A") | |
size = tx_data.get("size", None) | |
weight = tx_data.get("weight", "N/A") | |
fee_rate_str = f"{(fee * 1e8) / size:.2f} sat/byte" if size else "N/A" | |
status = tx_data.get("status", {}) | |
block_time = status.get("block_time") | |
fecha_hora_str = datetime.fromtimestamp(block_time).strftime('%Y-%m-%d %H:%M:%S') if block_time else "Desconocida" | |
# Generar grafo | |
G = generar_grafo(tx_data) | |
pos = nx.spring_layout(G, seed=42) | |
edge_x, edge_y = [], [] | |
for edge in G.edges(): | |
x0, y0 = pos[edge[0]] | |
x1, y1 = pos[edge[1]] | |
edge_x.extend([x0, x1, None]) | |
edge_y.extend([y0, y1, None]) | |
node_x, node_y, hover_texts, node_colors, node_sizes = [], [], [], [], [] | |
for node in G.nodes(): | |
x, y = pos[node] | |
node_x.append(x) | |
node_y.append(y) | |
tipo = G.nodes[node].get("tipo", "desconocido") | |
hover_texts.append(f"Tipo: {tipo}<br>ID: {node[:8]}...") | |
node_colors.append(G.nodes[node].get("color", "#FFFFFF")) | |
node_sizes.append(G.nodes[node].get("size", 10)) | |
fig = go.Figure( | |
data=[ | |
go.Scatter( | |
x=edge_x, y=edge_y, | |
line=dict(width=0.5, color="#888"), | |
hoverinfo="none", | |
mode="lines" | |
), | |
go.Scatter( | |
x=node_x, y=node_y, | |
mode="markers+text", | |
marker=dict(color=node_colors, size=node_sizes, line_width=1), | |
text=[node[:8] for node in G.nodes()], | |
hovertext=hover_texts, | |
hoverinfo="text" | |
) | |
], | |
layout=go.Layout( | |
title="Visualización de Conexiones de la Transacción", | |
showlegend=False, | |
margin=dict(b=0, l=0, r=0, t=40), | |
xaxis=dict(showgrid=False, zeroline=False, visible=False), | |
yaxis=dict(showgrid=False, zeroline=False, visible=False), | |
template="plotly_dark" | |
) | |
) | |
if es_mixer: | |
unique_outputs_dict = {} | |
for out in tx_data.get("vout", []): | |
amount = out.get("value", 0) / 1e8 | |
addr = out.get("scriptpubkey_address") | |
if addr: | |
unique_outputs_dict.setdefault(amount, []).append(addr) | |
unique_outputs_details = "<ul>" | |
for amt, addrs in unique_outputs_dict.items(): | |
unique_outputs_details += f"<li>{amt:.8f} BTC: {', '.join(addrs)}</li>" | |
unique_outputs_details += "</ul>" | |
mixer_metrics = ( | |
f"<p>📤 <strong>Outputs:</strong> {num_outputs}</p>" | |
f"<p>💰 <strong>Montos únicos en outputs:</strong> {montos_unicos}</p>" | |
f"<p><strong>Detalles de outputs únicos:</strong></p>{unique_outputs_details}" | |
) | |
else: | |
mixer_metrics = "" | |
total_input_str = f"{total_input_value:.8f} BTC" | |
total_input_str += f" (${total_input_value * btc_price:,.2f})" if btc_price else "" | |
total_output_str = f"{total_output_value:.8f} BTC" | |
total_output_str += f" (${total_output_value * btc_price:,.2f})" if btc_price else "" | |
fee_str = f"{fee:.8f} BTC" | |
fee_str += f" (${fee * btc_price:,.2f})" if btc_price else "" | |
# Se ha eliminado el <hr> para que la información aparezca de forma continua. | |
reporte = f""" | |
<div style="padding: 20px; border-radius: 10px;"> | |
<h3 style="color: {'#FF5252' if es_mixer else '#4CAF50'};"> | |
{'🔮 POSIBLE MIXER / COINJOIN' if es_mixer else '✅ Transacción Normal'} | |
</h3> | |
<p><strong>TXID:</strong> {tx_id}</p> | |
<p><strong>Fecha y Hora:</strong> {fecha_hora_str}</p> | |
<p><strong>Versión:</strong> {version}</p> | |
<p><strong>Tamaño:</strong> {size if size else 'N/A'} bytes</p> | |
<p><strong>Peso:</strong> {weight}</p> | |
<p><strong>Fee rate:</strong> {fee_rate_str}</p> | |
<p>📥 <strong>Inputs:</strong> {num_inputs}</p> | |
{mixer_metrics} | |
<p><strong>Total Entradas:</strong> {total_input_str}</p> | |
<p><strong>Total Salidas:</strong> {total_output_str}</p> | |
<p><strong>Fee:</strong> {fee_str}</p> | |
<p><strong>Estado de Confirmación:</strong> {"Confirmada" if status.get("confirmed", False) else "No confirmada"}</p> | |
{mixer_message} | |
<p style="font-size: 0.9em;"> | |
Nota: La detección se basa en heurísticas y en información adicional de blockchain.com. Se recomienda usar herramientas especializadas para un análisis forense completo. | |
</p> | |
</div> | |
""" | |
return reporte, fig, (reporte, fig) | |
except Exception as e: | |
return f"⚠️ Error durante el análisis: {str(e)}", None, None | |
def mostrar_modal(analysis_tuple): | |
""" | |
A partir de la tupla de análisis, devuelve un HTML que simula un modal emergente con el informe completo. | |
""" | |
if not analysis_tuple: | |
return "<p>No hay análisis generado.</p>" | |
report, _ = analysis_tuple | |
html_modal = f''' | |
<div id="modalOverlay" style=" | |
position: fixed; top: 0; left: 0; width: 100%; height: 100%; | |
background: rgba(0,0,0,0.8); z-index: 9999; overflow: auto;"> | |
<div style=" | |
position: relative; margin: 5% auto; padding: 20px; | |
background: inherit; max-width: 800px; border-radius: 10px;"> | |
{report} | |
<br><br> | |
<button onclick="document.getElementById('modalOverlay').remove();" | |
style='padding: 5px 10px; font-size: 12px;'>Cerrar</button> | |
</div> | |
</div> | |
''' | |
return html_modal | |
# ---------------- INTERFAZ GRÁFICA CON GRADIO ------------------- | |
with gr.Blocks( | |
theme=gr.themes.Soft(), | |
title="🔍 Detector de Mixers / CoinJoin en Transacciones Bitcoin", | |
css=".custom-btn { background-color: #ADD8E6 !important; color: black !important; padding: 5px 10px; font-size: 12px; }" | |
) as demo: | |
# Encabezado | |
gr.Markdown("# 🔍 Detector de Mixers / CoinJoin en Transacciones Bitcoin") | |
gr.Markdown("Desarrollado por José R. Leonett para la comunidad de Peritos Forenses Digitales de Guatemala [www.forensedigital.gt](http://www.forensedigital.gt)") | |
gr.Markdown("**Nota:** Este analizador funciona únicamente con transacciones de Bitcoin. No se pueden analizar transacciones de Ethereum.") | |
with gr.Row(): | |
# Columna izquierda: Campo de TXID, ejemplos y explicación de campos | |
with gr.Column(scale=1): | |
tx_input = gr.Textbox( | |
label="TXID de la Transacción", | |
placeholder="Ej: 9dd51e2d45f4f7bddcc3f0f7a05c3fd60543a11cfc9fbd0e1ca4434668cfa3e1" | |
) | |
analyze_btn = gr.Button("Analizar Transacción", elem_classes=["custom-btn"]) | |
gr.Markdown("### Ejemplos de TXIDs válidos:") | |
gr.Examples( | |
examples=[ | |
["0042c32659ef432276a0feb59e0418dee97e80ecd0ffa8a3f6228744388e65ac"], | |
["cdacd625a332f46913209aa3480002fc5beb67d031e5b99a0bfe3fddbaef402b"] | |
], | |
inputs=tx_input | |
) | |
explanation_box = gr.HTML(value=""" | |
<div style="overflow-y: auto; height: 350px; border: 1px solid #cccccc; padding: 10px;"> | |
<h4>Explicación de los campos:</h4> | |
<ul> | |
<li><strong>TXID:</strong> Identificador único de la transacción.</li> | |
<li><strong>Fecha y Hora:</strong> Momento en que la transacción fue confirmada (según la blockchain).</li> | |
<li><strong>Versión:</strong> Versión del protocolo de la transacción.</li> | |
<li><strong>Tamaño:</strong> Tamaño en bytes de la transacción.</li> | |
<li><strong>Peso:</strong> Peso de la transacción en unidades de peso.</li> | |
<li><strong>Fee rate:</strong> Tarifa pagada por byte (sat/byte).</li> | |
<li><strong>Inputs:</strong> Número de entradas de la transacción.</li> | |
<li><strong>Outputs:</strong> Se muestran solo si se detecta posible mixer.</li> | |
<li><strong>Montos únicos en outputs:</strong> Se muestran solo si es mixer.</li> | |
<li><strong>Detalles de outputs únicos:</strong> Lista de cada monto (en BTC) y las direcciones asociadas (solo si es mixer).</li> | |
<li><strong>Total Entradas:</strong> Suma total de los valores de entrada (en BTC y USD).</li> | |
<li><strong>Total Salidas:</strong> Suma total de los valores de salida (en BTC y USD).</li> | |
<li><strong>Fee:</strong> Diferencia entre entradas y salidas (en BTC y USD).</li> | |
<li><strong>Estado de Confirmación:</strong> Indica si la transacción está confirmada o no.</li> | |
<li><strong>Alerta de Mixer / CoinJoin:</strong> Muestra si la transacción presenta patrones de mezcla.</li> | |
</ul> | |
</div> | |
""") | |
# Columna derecha: Grafo, Reporte y botón para mostrar modal con análisis completo | |
with gr.Column(scale=1): | |
plot_output = gr.Plot() | |
reporte_html = gr.HTML() | |
modal_btn = gr.Button("GENERAR ANALISIS", elem_classes=["custom-btn"]) | |
modal_output = gr.HTML() | |
# Estado para almacenar el análisis (tupla: (reporte, fig)) | |
analysis_state = gr.State() | |
# Al pulsar "Analizar Transacción", se generan reporte, grafo y se guarda el análisis. | |
analyze_btn.click(fn=analizar_transaccion, inputs=tx_input, outputs=[reporte_html, plot_output, analysis_state]) | |
# Al pulsar "GENERAR ANALISIS", se muestra un modal emergente con el informe completo. | |
modal_btn.click(fn=mostrar_modal, inputs=analysis_state, outputs=modal_output) | |
demo.launch() | |